/// import { assert, assertEquals } from "@std/assert"; import { dirname, fromFileUrl, join } from "@std/path"; import { chromium, type Page } from "playwright"; const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../.."); const workspaceRoot = join(repositoryRoot, "web/workspace"); const fixtureServer = join( repositoryRoot, "tools/web-ux/browser-tests/console_history_fixture_server.ts", ); const workspaceId = "console-history-review"; type MotionWindow = Window & { latestMotion: { from: number; to: number; duration: number }[]; }; async function freePort(): Promise { const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); const port = (listener.addr as Deno.NetAddr).port; listener.close(); return port; } async function waitForServer(url: string): Promise { for (let attempt = 0; attempt < 200; attempt += 1) { try { const response = await fetch(url); await response.body?.cancel(); if (response.ok) return; } catch { // Retry until the bounded deadline while the owned server starts. } await new Promise((resolve) => setTimeout(resolve, 50)); } throw new Error(`fixture server did not become ready: ${url}`); } async function historyRequestCount(baseUrl: string): Promise { const response = await fetch(`${baseUrl}/fixture-state`); const state = await response.json(); return state.history_requests; } async function checkSidebarModeSwitch(page: Page, name: string): Promise { const button = page.getByRole("button", { name, exact: true }); await button.hover(); await button.focus(); const samples = await button.evaluate(async (button) => { const frame = button.closest(".sidebar-frame")!; await Promise.all(frame.getAnimations({ subtree: true }).map((animation) => animation.finished)); const panel = frame.querySelector(".sidebar-frame__bevel")!; const content = frame.querySelector(".sidebar-frame-content")!; const main = document.querySelector(".app-shell__main")!; const sample = () => ({ mainLeft: main.getBoundingClientRect().left, mainWidth: main.getBoundingClientRect().width, stationary: { panel: panel.getBoundingClientRect().toJSON(), content: content.getBoundingClientRect().toJSON(), button: button.getBoundingClientRect().toJSON(), folded: frame.classList.contains("folded"), unclipped: panel.contains(document.elementFromPoint(panel.getBoundingClientRect().right - 8, 40)), motion: panel.getAnimations({ subtree: true }).filter((animation) => animation instanceof CSSTransition && ["width", "transform"].includes(animation.transitionProperty) ).length, }, }); const samples = [sample()]; (button as HTMLButtonElement).click(); const start = performance.now(); do { await new Promise((resolve) => requestAnimationFrame(() => resolve())); samples.push(sample()); } while (performance.now() - start < 280); return samples; }); for (const sample of samples) { assertEquals(sample.stationary, samples[0].stationary, `${name} must keep the open panel stationary without slide animation`); assertEquals(sample.stationary.folded, false); assertEquals(sample.stationary.motion, 0); assertEquals(sample.stationary.unclipped, true); } const first = samples[0]; const last = samples.at(-1)!; assert(samples.some((sample) => sample.mainLeft > Math.min(first.mainLeft, last.mainLeft) && sample.mainLeft < Math.max(first.mainLeft, last.mainLeft)), "main position must animate through intermediate coordinates"); assert(samples.some((sample) => sample.mainWidth > Math.min(first.mainWidth, last.mainWidth) && sample.mainWidth < Math.max(first.mainWidth, last.mainWidth)), "main width must animate through intermediate sizes"); await page.locator(".sidebar-fold-button").evaluate((button) => (button as HTMLButtonElement).blur()); } async function checkSidebarPointerModeSwitch(page: Page, pinning: boolean): Promise { const button = page.getByRole("button", { name: pinning ? "Pin sidebar" : "Unpin sidebar", exact: true }); await button.hover(); await page.mouse.down(); await page.waitForTimeout(100); assertEquals(await page.locator(".sidebar-frame.folded").count(), pinning ? 1 : 0, "pointer-down must not open a preview before the mode changes on click"); const sampling = button.evaluate(async (button) => { const frame = button.closest(".sidebar-frame")!; const panel = frame.querySelector(".sidebar-frame__bevel")!; const main = document.querySelector(".app-shell__main")!; const content = frame.querySelector(".sidebar-frame-content")!; const sample = () => ({ frameWidth: frame.getBoundingClientRect().width, panelWidth: panel.getBoundingClientRect().width, mainWidth: main.getBoundingClientRect().width, expectedMainWidth: Math.min(parseFloat(getComputedStyle(main).maxWidth), innerWidth - panel.getBoundingClientRect().right), contentWidth: content.getBoundingClientRect().width, buttonLeft: button.getBoundingClientRect().left, buttonBottom: button.getBoundingClientRect().bottom, }); const samples = [sample()]; const start = performance.now(); do { await new Promise((resolve) => requestAnimationFrame(() => resolve())); samples.push(sample()); } while (performance.now() - start < 450); return samples; }); await page.mouse.up(); const samples = await sampling; for (const sample of samples) { assert(Math.abs(sample.frameWidth - sample.panelWidth) <= 1, `sidebar and main must move together throughout the click: ${JSON.stringify(sample)}`); assert(Math.abs(sample.mainWidth - sample.expectedMainWidth) <= 1); assertEquals(sample.contentWidth, samples[0].contentWidth); assertEquals(sample.buttonLeft, samples[0].buttonLeft); assertEquals(sample.buttonBottom, samples[0].buttonBottom); } const first = samples[0].frameWidth; const last = samples.at(-1)!.frameWidth; assert(pinning ? last > first : last < first); assert(samples.some((sample) => sample.frameWidth > Math.min(first, last) && sample.frameWidth < Math.max(first, last)), "mode switch must animate through intermediate widths"); assertEquals(await page.locator(".sidebar-frame.folded").count(), pinning ? 0 : 1); } async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise { const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-hover-region"); const result = await toggle.evaluate(async (button, { mobile, opening }) => { const frame = document.querySelector(".sidebar-frame")!; const content = frame.querySelector(".sidebar-frame-content")!; const main = document.querySelector(".app-shell__main")!; const moving = mobile ? frame : content; const footer = frame.querySelector(".sidebar-control-row")!; const pin = frame.querySelector(".sidebar-fold-button")!; const sample = () => ({ footerWidth: footer.getBoundingClientRect().width, footerHeight: footer.getBoundingClientRect().height, dividerLeft: parseFloat(getComputedStyle(footer, "::before").left), dividerRight: parseFloat(getComputedStyle(footer, "::before").right), footerBorder: getComputedStyle(footer).borderTopWidth, pinWidth: pin.getBoundingClientRect().width, pinHeight: pin.getBoundingClientRect().height, x: moving.getBoundingClientRect().x, width: moving.getBoundingClientRect().width, frameWidth: frame.getBoundingClientRect().width, panelWidth: frame.querySelector(".sidebar-frame__bevel")!.getBoundingClientRect().width, mainWidth: main.getBoundingClientRect().width, mainHeight: main.getBoundingClientRect().height, pinLeft: frame.querySelector(".sidebar-fold-button")!.getBoundingClientRect().left - frame.getBoundingClientRect().left, pinBottom: frame.getBoundingClientRect().bottom - frame.querySelector(".sidebar-fold-button")!.getBoundingClientRect().bottom, }); const before = sample(); if (mobile) { (button as HTMLButtonElement).click(); } else { button.dispatchEvent(new PointerEvent(opening ? "pointerenter" : "pointerleave", { pointerType: "mouse" })); if (!opening) await new Promise((resolve) => setTimeout(resolve, 190)); } await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); const animations = frame.getAnimations({ subtree: true }); const slides = animations.filter((animation) => animation instanceof CSSTransition && ["width", "transform"].includes(animation.transitionProperty)); const durations = slides.map((animation) => animation.effect!.getTiming().duration); for (const animation of animations) { animation.pause(); animation.currentTime = 110; } const middle = sample(); for (const animation of animations) animation.finish(); await Promise.all(animations.map((animation) => animation.finished)); return { before, middle, after: sample(), durations, contentInert: content.inert, mainInert: main.inert }; }, { mobile, opening }); assert(result.durations.length >= (mobile ? 1 : 2), `missing sidebar motion: ${JSON.stringify({ mobile, opening, result })}`); assert(result.durations.every((duration) => duration === 220)); const { before, middle, after } = result; for (const sample of [before, middle, after]) { assert(Math.abs(sample.pinLeft - 8) <= 1, "Pin must stay at the sidebar's left edge"); assert(Math.abs(sample.pinBottom - 8) <= 1, "Pin must stay at the sidebar's bottom edge"); assertEquals(sample.pinWidth, 32); assertEquals(sample.pinHeight, 32); assertEquals(sample.footerHeight, 48, "footer keeps compact, equal padding"); assertEquals(sample.dividerLeft, 8); assertEquals(sample.dividerRight, 8); assertEquals(sample.footerBorder, "0px", "divider must not touch the sidebar edges"); } assert(opening ? before.x < middle.x && middle.x < after.x : before.x > middle.x && middle.x > after.x, `sidebar must slide horizontally through an intermediate position: ${JSON.stringify(result)}`); assertEquals(before.width, after.width, "sidebar contents must slide without reflowing"); assertEquals(result.contentInert, !opening); assertEquals(result.mainInert, mobile && opening); assertEquals(before.mainWidth, after.mainWidth, "hover/touch previews must not resize the main content"); assertEquals(before.mainHeight, after.mainHeight); assertEquals(after.frameWidth, before.frameWidth, "hover mode keeps a fixed rail width"); if (!mobile) { const folded = opening ? before : after; assertEquals(folded.footerWidth, folded.footerHeight, "folded footer must be square"); assert(opening ? before.panelWidth < middle.panelWidth && middle.panelWidth < after.panelWidth : before.panelWidth > middle.panelWidth && middle.panelWidth > after.panelWidth, "the overlay panel must expand past the rail and contract again"); } } async function checkConsoleHistory(viewportHeight: number): Promise { const build = await new Deno.Command(Deno.execPath(), { args: ["task", "build"], cwd: workspaceRoot, stdout: "piped", stderr: "piped", }).output(); if (!build.success) { throw new Error( `Workspace production build failed:\n${new TextDecoder().decode(build.stderr)}`, ); } const port = await freePort(); const baseUrl = `http://127.0.0.1:${port}`; const server = new Deno.Command(Deno.execPath(), { args: [ "run", "--allow-net", "--allow-read", fixtureServer, String(port), join(workspaceRoot, "build"), ], stdout: "null", stderr: "null", }).spawn(); try { await waitForServer(`${baseUrl}/health`); const browser = await chromium.launch({ headless: true }); try { const context = await browser.newContext({ viewport: { width: 1440, height: viewportHeight }, colorScheme: "dark", }); const page = await context.newPage(); page.setDefaultTimeout(5_000); await page.addInitScript(() => { (window as unknown as MotionWindow).latestMotion = []; const animate = Element.prototype.animate; Element.prototype.animate = function (frames, options) { if (this.matches(".console-jump-latest") && Array.isArray(frames) && frames.length > 0) { (window as unknown as MotionWindow).latestMotion.push({ from: Number(frames[0].opacity), to: Number(frames.at(-1)?.opacity), duration: Number(typeof options === "number" ? options : options?.duration), }); } return animate.call(this, frames, options); }; }); const errors: string[] = []; const responses: string[] = []; page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); page.on("pageerror", (error) => errors.push(String(error))); page.on("response", (response) => { if (response.status() >= 400 || response.url().includes("/api/")) { responses.push(`${response.status()} ${response.url()}`); } }); await page.goto( `${baseUrl}/w/${workspaceId}/workers/W-900-console-fixture/console`, ); const transcript = page.getByRole("article", { name: "main transcript" }); try { await transcript.getByText("question 8", { exact: true }).waitFor({ timeout: 5_000 }); } catch (error) { throw new Error( `Console did not render retained history: ${error}\nresponses=${ JSON.stringify(responses) }\nerrors=${JSON.stringify(errors)}\nbody=${await page.locator("body").innerText()}`, ); } const navigation = page.getByRole("navigation", { name: "Conversation turns" }); await navigation.getByRole("button", { name: /^Turn \d+: question 7$/ }).waitFor(); assertEquals(await historyRequestCount(baseUrl), 1); assertEquals(await transcript.getByText(/^question (7|8|9|10|11|12)$/).count(), 6); assertEquals(await navigation.getByRole("button", { name: /^Turn \d+: question (7|8|9|10|11|12)$/ }).count(), 6); assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 6, "overlapping snapshot and history must render each tool activity once"); const turnList = navigation.locator(".turn-list"); const initialGeometry = await turnList.evaluate((element) => { const boundary = element.querySelector(".history-boundary")!; const control = boundary.firstElementChild!; const first = element.querySelector(".turn-button")!; return { boundaryHeight: boundary.getBoundingClientRect().height, controlHeight: control.getBoundingClientRect().height, boundaryOffset: boundary.getBoundingClientRect().top - element.getBoundingClientRect().top + element.scrollTop, firstOffset: first.getBoundingClientRect().top - boundary.getBoundingClientRect().top, expectedOffset: Math.max(0, (element.getBoundingClientRect().height - element.children.length * 24) / 2), }; }); assertEquals(initialGeometry.boundaryHeight, 24); assertEquals(initialGeometry.controlHeight, 24); assertEquals(initialGeometry.firstOffset, 24); assert(Math.abs(initialGeometry.boundaryOffset - initialGeometry.expectedOffset) <= 1, "short turn lists must be vertically centered, with overflowing lists starting at the top"); assertEquals(await turnList.evaluate((element) => element.scrollHeight > element.clientHeight), viewportHeight === 340); const currentQuestionTop = await transcript .getByText("question 7", { exact: true }) .evaluate((element) => element.getBoundingClientRect().top); const retainedQuestionTop = await transcript .getByText("question 8", { exact: true }) .evaluate((element) => element.getBoundingClientRect().top); assert( currentQuestionTop < retainedQuestionTop, "the unmatched current-snapshot turn must precede overlapping retained history", ); const question12 = navigation.getByRole("button", { name: /^Turn \d+: question 12$/, }); await question12.scrollIntoViewIfNeeded(); await page.waitForTimeout(50); await question12.hover(); const preview = navigation.getByRole("tooltip"); await preview.waitFor(); assert((await preview.textContent())?.includes("detail 12.1")); assert((await preview.textContent())?.includes("detail 12.2")); assert(!(await preview.textContent())?.includes("detail 12.3")); await page.keyboard.press("Escape"); const consoleScroll = transcript.locator("xpath=.."); const topAlignment = await page.evaluate(() => { const document = (globalThis as any).document; const body = document.querySelector(".console-scroll")?.getBoundingClientRect(); const turns = document.querySelector(".turn-navigation")?.getBoundingClientRect(); return body && turns ? Math.abs(body.top - turns.top) : Number.POSITIVE_INFINITY; }); assert(topAlignment <= 1, `transcript and turn bar top edges differ by ${topAlignment}px`); const navigationAnchoredTop = await navigation .locator('[data-turn-id="user-8"]') .evaluate((element) => element.getBoundingClientRect().top); const anchoredTop = await consoleScroll.evaluate((element) => { element.scrollTop = 0; const anchor = element.querySelector('[data-console-line-id*="user-8"]'); const top = anchor?.getBoundingClientRect().top ?? Number.NaN; element.dispatchEvent(new Event("scroll")); return top; }); await transcript.getByText("question 3", { exact: true }).waitFor(); await navigation.getByRole("button", { name: /^Turn \d+: question 3$/ }).waitFor(); const restoredTop = await transcript .locator('[data-console-line-id*="user-8"]') .evaluate((element) => element.getBoundingClientRect().top); assert( Math.abs(restoredTop - anchoredTop) <= 1, `transcript anchor moved from ${anchoredTop}px to ${restoredTop}px`, ); await page.waitForTimeout(250); assertEquals( await historyRequestCount(baseUrl), 2, "a stationary top sentinel must not drain another page", ); const navigationAfterPrepend = await navigation .locator('[data-turn-id="user-8"]') .evaluate((element) => element.getBoundingClientRect().top); const overflowing = await turnList.evaluate((element) => element.scrollHeight > element.clientHeight); assertEquals(overflowing, viewportHeight < 600); if (overflowing) { const scroll = await turnList.evaluate((element) => ({ top: element.scrollTop, max: element.scrollHeight - element.clientHeight, })); // When a short list first overflows, the requested anchor can exceed // the scroll range. Restore as far as possible without artificial space. const requested = scroll.top + navigationAfterPrepend - navigationAnchoredTop; assert(Math.abs(scroll.top - Math.max(0, Math.min(scroll.max, requested))) <= 1); if (viewportHeight === 340) { assert( Math.abs(navigationAfterPrepend - navigationAnchoredTop) <= 1, `turn-bar anchor moved from ${navigationAnchoredTop}px to ${navigationAfterPrepend}px`, ); } await turnList.evaluate((element) => { element.scrollTop = Math.max(2, element.scrollTop); element.dispatchEvent(new Event("scroll")); element.scrollTop = 0; element.dispatchEvent(new Event("scroll")); }); } else { // A short list cannot scroll: keep the whole compact list centered // rather than adding artificial space to force a fixed anchor. const centerOffset = await turnList.evaluate((element) => { const first = element.firstElementChild!.getBoundingClientRect(); const last = element.lastElementChild!.getBoundingClientRect(); const viewport = element.getBoundingClientRect(); return (first.top + last.bottom - viewport.top - viewport.bottom) / 2; }); assert(Math.abs(centerOffset) <= 1, "short lists must remain centered after prepending history"); await navigation.getByRole("button", { name: "Earlier conversation available" }).click(); } await transcript.getByText("question 1", { exact: true }).waitFor(); await navigation.getByRole("button", { name: /^Turn \d+: question 1$/ }).waitFor(); assertEquals(await historyRequestCount(baseUrl), 3); assertEquals(await page.getByText("Start of conversation", { exact: true }).count(), 1); await navigation.getByRole("button", { name: /^Turn \d+: question 3$/, }).click(); await page.waitForFunction(() => { const document = (globalThis as any).document; const element = document.querySelector('[data-console-line-id*="user-3"]'); const parent = element?.closest(".console-scroll"); if (!element || !parent) return false; const item = element.getBoundingClientRect(); const viewport = parent.getBoundingClientRect(); return item.top >= viewport.top - 1 && item.top < viewport.bottom; }); const jumpGeometry = await transcript .locator('[data-console-line-id*="user-3"]') .evaluate((element) => { const parent = element.closest(".console-scroll")!; const item = element.getBoundingClientRect(); const viewport = parent.getBoundingClientRect(); return { itemTop: item.top, viewportTop: viewport.top, viewportBottom: viewport.bottom }; }); assert(jumpGeometry.itemTop >= jumpGeometry.viewportTop - 1); assert(jumpGeometry.itemTop < jumpGeometry.viewportBottom); const latest = page.getByRole("button", { name: "Jump to latest", exact: true }); await latest.waitFor(); await latest.evaluate(async (element) => { await Promise.all(element.getAnimations().map((animation) => animation.finished)); }); await latest.hover(); await page.waitForFunction(() => getComputedStyle(document.querySelector(".console-jump-latest")!).translate === "0px -2px"); assert((await latest.evaluate((element) => getComputedStyle(element).transitionDuration)).includes("0.14s")); await page.mouse.move(0, 0); await latest.evaluate(async (element) => { await Promise.all(element.getAnimations().map((animation) => animation.finished)); }); assertEquals((await latest.textContent())?.trim(), ""); const viewportBeforeJump = await consoleScroll.boundingBox(); const latestGeometry = await latest.boundingBox(); assert(viewportBeforeJump && latestGeometry); assert(Math.abs(latestGeometry.x + latestGeometry.width / 2 - viewportBeforeJump.x - viewportBeforeJump.width / 2) <= 1, "latest icon must be centered over the transcript, not the rail or Composer"); const bottomGap = viewportBeforeJump.y + viewportBeforeJump.height - latestGeometry.y - latestGeometry.height; assert(bottomGap >= 8 && bottomGap <= 24, `unexpected latest icon bottom gap: ${bottomGap}`); const railTop = await turnList.evaluate((element) => element.scrollTop); await latest.click(); await latest.waitFor({ state: "detached" }); assert(await consoleScroll.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop <= 1)); assertEquals(await consoleScroll.boundingBox(), viewportBeforeJump, "overlay must not resize the transcript"); assertEquals(await turnList.evaluate((element) => element.scrollTop), railTop); await consoleScroll.evaluate((element) => { element.scrollTop = element.scrollHeight / 2; }); await latest.waitFor(); await latest.focus(); await page.keyboard.press("Enter"); await latest.waitFor({ state: "detached" }); await consoleScroll.evaluate((element) => { element.scrollTop = element.scrollHeight / 2; }); await latest.waitFor(); await consoleScroll.evaluate((element) => { element.scrollTop = element.scrollHeight; }); await latest.waitFor({ state: "detached" }); const motion = await page.evaluate(() => (window as unknown as MotionWindow).latestMotion); assert(motion.some((animation) => animation.from === 0 && animation.to === 1 && animation.duration === 160), "latest icon must animate in"); assert(motion.some((animation) => animation.from === 1 && animation.to === 0 && animation.duration === 160), "latest icon must animate out"); await page.emulateMedia({ reducedMotion: "reduce" }); await page.evaluate(() => { (window as unknown as MotionWindow).latestMotion = []; }); await consoleScroll.evaluate((element) => { element.scrollTop = element.scrollHeight / 2; }); await latest.waitFor(); await latest.hover(); const reducedStyle = await latest.evaluate((element) => { const style = getComputedStyle(element); return { translate: style.translate, duration: style.transitionDuration }; }); assertEquals(reducedStyle, { translate: "none", duration: "0s" }); await latest.click(); await latest.waitFor({ state: "detached" }); assertEquals(await page.evaluate(() => (window as unknown as MotionWindow).latestMotion), [], "reduced motion must skip enter and exit animations"); await page.emulateMedia({ reducedMotion: "no-preference" }); const centering = await question12.evaluate((button) => { const turn = button.getBoundingClientRect(); const bar = button.querySelector(".turn-bar")!.getBoundingClientRect(); return Math.abs((turn.top + turn.bottom) / 2 - (bar.top + bar.bottom) / 2); }); assert(centering <= 1, `turn marker is not vertically centered (${centering}px)`); assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12); const overview = page.getByRole("switch", { name: "Overview", exact: true }); assertEquals(await overview.getAttribute("aria-checked"), "true"); await overview.click(); assertEquals(await overview.getAttribute("aria-checked"), "false"); const rowIds = await transcript.locator("[data-console-line-id]").evaluateAll((rows) => rows.map((row) => row.getAttribute("data-console-line-id"))); assertEquals(rowIds.length, 48); assertEquals(new Set(rowIds).size, rowIds.length); await overview.click(); assertEquals(await overview.getAttribute("aria-checked"), "true"); assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12); const sidebar = page.locator(".sidebar-frame"); const settleSidebar = () => sidebar.evaluate(async (element) => { await Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished)); }); await checkSidebarModeSwitch(page, "Unpin sidebar"); await page.mouse.move(1000, 30); await page.locator(".sidebar-frame.folded").waitFor(); await settleSidebar(); const mainBeforeHover = await page.locator(".app-shell__main").boundingBox(); await checkSidebarSlide(page, false, true); await checkSidebarSlide(page, false, false); // The entire footer, including its button, is outside hover activation. const footer = sidebar.locator(".sidebar-control-row"); await footer.hover({ position: { x: 4, y: 4 } }); await page.waitForTimeout(250); assertEquals(await page.locator(".sidebar-frame.folded").count(), 1); await page.getByRole("button", { name: "Pin sidebar", exact: true }).hover(); await page.waitForTimeout(250); assertEquals(await page.locator(".sidebar-frame.folded").count(), 1); await sidebar.locator(".sidebar-hover-region").hover({ position: { x: 10, y: 24 } }); await page.locator(".sidebar-frame:not(.folded)").waitFor(); await settleSidebar(); await footer.hover({ position: { x: 4, y: 4 } }); await page.locator(".sidebar-frame.folded").waitFor(); await settleSidebar(); await checkSidebarPointerModeSwitch(page, true); await checkSidebarPointerModeSwitch(page, false); // Use real mouse and keyboard events as well as deterministic motion sampling. await sidebar.hover({ position: { x: 10, y: 40 } }); await page.locator(".sidebar-frame:not(.folded)").waitFor(); await settleSidebar(); assertEquals(await page.locator(".app-shell__main").boundingBox(), mainBeforeHover); const sidebarLink = sidebar.locator(".sidebar-frame-content a").first(); await sidebarLink.focus(); await page.mouse.move(1000, 30); await page.waitForTimeout(250); assertEquals(await page.locator(".sidebar-frame.folded").count(), 0, "keyboard focus must hold the preview open"); await page.getByRole("link", { name: "Open Account", exact: true }).focus(); await page.locator(".sidebar-frame.folded").waitFor(); await settleSidebar(); await sidebar.hover({ position: { x: 10, y: 40 } }); await checkSidebarModeSwitch(page, "Pin sidebar"); await page.mouse.move(1000, 30); await page.waitForTimeout(250); assertEquals(await page.locator(".sidebar-frame.folded").count(), 0); assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); await checkSidebarModeSwitch(page, "Unpin sidebar"); // Hold a preview open across reload; only the mode should be restored. await sidebarLink.focus(); await page.mouse.move(1000, 30); assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover"); await page.reload(); await page.getByRole("button", { name: "Pin sidebar", exact: true }).waitFor(); assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0); await page.setViewportSize({ width: 600, height: viewportHeight }); await settleSidebar(); await checkSidebarSlide(page, true, true); await checkSidebarSlide(page, true, false); await checkSidebarSlide(page, true, true); assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover"); await page.reload(); await page.getByRole("button", { name: "Show sidebar", exact: true }).waitFor(); assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0); await page.getByRole("button", { name: "Show sidebar", exact: true }).click(); await settleSidebar(); await page.emulateMedia({ reducedMotion: "reduce" }); await page.getByRole("button", { name: "Hide sidebar", exact: true }).click(); const reducedSidebar = await page.locator(".sidebar-frame").evaluate((element) => ({ animations: element.getAnimations({ subtree: true }).length, right: element.getBoundingClientRect().right, visibility: getComputedStyle(element).visibility, })); assertEquals(reducedSidebar, { animations: 0, right: 0, visibility: "hidden" }); assertEquals(await page.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false); if (viewportHeight === 600) { const touchContext = await browser.newContext({ viewport: { width: 600, height: 600 }, hasTouch: true, isMobile: true, storageState: await context.storageState(), }); const touchPage = await touchContext.newPage(); await touchPage.goto(page.url()); await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap(); await touchPage.getByRole("button", { name: "Pin sidebar", exact: true }).tap(); assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap(); await touchPage.reload(); await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor(); assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); // Different-route and same-page links both dismiss the mobile overlay. for (let visit = 0; visit < 2; visit++) { await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap(); await touchPage.getByRole("link", { name: "Workspace home", exact: true }).tap(); await touchPage.waitForURL(`**/w/${workspaceId}`); await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor(); assertEquals(await touchPage.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false); assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); } await touchPage.goto(page.url()); // A wide touchscreen still has a temporary-open control in hover mode. await touchPage.setViewportSize({ width: 1024, height: 600 }); await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap(); await touchPage.locator(".sidebar-frame.folded").waitFor(); await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap(); await touchPage.locator(".sidebar-frame:not(.folded)").waitFor(); await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap(); await touchPage.locator(".sidebar-frame.folded").waitFor(); assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover"); await touchContext.close(); } assertEquals(errors, []); await context.close(); } finally { await browser.close(); } } finally { server.kill("SIGTERM"); await server.status; } } for (const viewportHeight of [600, 400, 340]) { Deno.test(`production Console keeps compact centered turns and pages history at ${viewportHeight}px`, () => checkConsoleHistory(viewportHeight)); }