diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts index 214a7881c1..4d7afbd4b9 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -10,6 +10,15 @@ import { gotoAppShell, openSettings } from "../support/helpers/app"; import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { closeCompactSettings, + scrollSettingsSidebarToEnd, + revealSettingsSidebarSection, + expectSettingsSidebarScrollSettled, + scrollSettingsSidebarUp, + changeSettingsInterfaceFontSize, + selectSettingsHostSectionsRapidly, + expectSettingsSidebarScrollOffset, + scrollSettingsDetailDown, + expectSettingsDetailScrollOffset, openSettingsSection, expectSettingsHeader, openAddHostFlow, @@ -54,6 +63,98 @@ async function openWorkspace( await expect(page.getByTestId("menu-button")).toBeVisible(); } +test.describe("Settings sidebar scroll position", () => { + test.use({ viewport: { width: 1000, height: 500 } }); + + test("clamps after resizing and starts a fresh visit at the top", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + await scrollSettingsSidebarToEnd(page); + await page.setViewportSize({ width: 1000, height: 1100 }); + await expectSettingsSidebarScrollOffset(page, 0); + await page.setViewportSize({ width: 1000, height: 500 }); + await openSettingsSection(page, "appearance"); + await expectSettingsSidebarScrollOffset(page, 0); + await scrollSettingsSidebarToEnd(page); + await clickSettingsBackToWorkspace(page); + await openSettings(page); + await expectSettingsSidebarScrollOffset(page, 0); + }); + + test("keeps the sidebar position through interface font changes", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + await openSettingsSection(page, "appearance"); + const offset = await scrollSettingsSidebarToEnd(page); + await changeSettingsInterfaceFontSize(page, 20); + await expectSettingsSidebarScrollOffset(page, offset); + await openSettingsHostSection(page, getServerId(), "providers"); + await expectSettingsSidebarScrollOffset(page, offset); + }); + + test("scrolls the sidebar and detail pane independently", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + const sidebarOffset = await scrollSettingsSidebarToEnd(page); + await openSettingsHostSection(page, getServerId(), "providers"); + await expect(page.getByTestId("host-page-providers-card")).toBeVisible(); + const detailOffset = await scrollSettingsDetailDown(page); + await expectSettingsSidebarScrollOffset(page, sidebarOffset); + + await scrollSettingsSidebarUp(page, 60); + await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60); + await expectSettingsDetailScrollOffset(page, detailOffset); + }); + + test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + + const scrollTop = await scrollSettingsSidebarToEnd(page); + + await selectSettingsHostSectionsRapidly(page); + await expectSettingsSidebarScrollOffset(page, scrollTop); + + await expectSettingsSidebarScrollSettled(page, scrollTop); + }); + + test("keeps the sidebar position when switching between app and host sections", async ({ + page, + }) => { + await gotoAppShell(page); + await openSettings(page); + + const hostScrollTop = await scrollSettingsSidebarToEnd(page); + await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport(); + + for (const section of [ + "providers", + "terminals", + "providers", + "terminals", + "providers", + ] as const) { + await openSettingsHostSection(page, getServerId(), section); + await expect(page.getByTestId("page-title")).toHaveText( + section === "providers" ? "Providers" : "Terminals", + ); + await expectSettingsSidebarScrollOffset(page, hostScrollTop); + } + + const sidebar = page.getByTestId("settings-sidebar"); + const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics"); + await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport(); + + await openSettingsSection(page, "about"); + await expectAboutContent(page); + await expectSettingsSidebarScrollOffset(page, appScrollTop); + + await openSettingsSection(page, "diagnostics"); + await expectDiagnosticsContent(page); + await expectSettingsSidebarScrollOffset(page, appScrollTop); + }); +}); + test.describe("Settings sidebar navigation", () => { test("opening Settings closes the composer model selector until it is opened again", async ({ page, diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index e68f554f34..69791f813a 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -57,6 +57,92 @@ export async function openSettingsSection(page: Page, section: SettingsSection): await expectAppRoute(page, buildSettingsSectionRoute(section)); } +export async function revealSettingsSidebarSection( + page: Page, + section: SettingsSection, +): Promise { + await page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: SECTION_LABELS[section], exact: true }) + .scrollIntoViewIfNeeded(); + const offset = await page + .getByTestId("settings-sidebar-scroll-body") + .evaluate((node) => node.scrollTop); + expect(offset).toBeGreaterThan(0); + return offset; +} + +export async function expectSettingsSidebarScrollSettled( + page: Page, + offset: number, +): Promise { + // RN Web emits its final scroll callback 100 ms after scrolling stops. + await page.waitForTimeout(200); + await expectSettingsSidebarScrollOffset(page, offset); +} + +export async function selectSettingsHostSectionsRapidly(page: Page): Promise { + for (let selection = 0; selection < 5; selection += 1) { + await page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: "Providers", exact: true }) + .click(); + await page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: "Terminals", exact: true }) + .click(); + await expectSettingsHeader(page, "Terminals"); + } +} + +export async function scrollSettingsSidebarUp(page: Page, pixels: number): Promise { + await page.getByTestId("settings-sidebar-scroll-body").hover(); + await page.mouse.wheel(0, -pixels); +} + +export async function changeSettingsInterfaceFontSize(page: Page, size: number): Promise { + const input = page.getByRole("textbox", { name: "Interface font size" }); + await input.fill(String(size)); + await input.press("Tab"); + await expect(input).toHaveValue(String(size)); +} + +export async function scrollSettingsSidebarToEnd(page: Page): Promise { + const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible'); + await scrollBody.hover(); + await page.mouse.wheel(0, 2000); + await expect + .poll(() => + scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop), + ) + .toBe(0); + const offset = await scrollBody.evaluate((node) => node.scrollTop); + expect(offset).toBeGreaterThan(0); + return offset; +} + +export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise { + const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible'); + await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset); +} + +export async function scrollSettingsDetailDown(page: Page): Promise { + const scrollBody = page.getByTestId("settings-detail-scroll-body"); + await expect + .poll(() => scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight)) + .toBeGreaterThan(0); + await scrollBody.hover(); + await page.mouse.wheel(0, 1000); + await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + const offset = await scrollBody.evaluate((node) => node.scrollTop); + return offset; +} + +export async function expectSettingsDetailScrollOffset(page: Page, offset: number): Promise { + const scrollBody = page.getByTestId("settings-detail-scroll-body"); + await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset); +} + export async function openSettingsHost(page: Page, serverId: string): Promise { // Host sections are now flat top-level rows under the Host group. Navigate by // clicking the Connections section row; the picker only matters when >1 host. diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index f6db461c09..083c6b2144 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -72,7 +72,7 @@ import { type StartupBlocker, } from "@/navigation/host-runtime-bootstrap"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; -import { useSettingsRouteEntry } from "@/navigation/settings-selection"; +import { useSettingsRouteEntry } from "@/navigation/settings-presentation"; import { ThemedStack } from "@/navigation/themed-stack"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { AgentNavigationListener } from "@/desktop/agent-navigation"; diff --git a/packages/app/src/navigation/settings-presentation/index.ts b/packages/app/src/navigation/settings-presentation/index.ts new file mode 100644 index 0000000000..cdf86b0d19 --- /dev/null +++ b/packages/app/src/navigation/settings-presentation/index.ts @@ -0,0 +1,57 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; +import { ScrollView, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native"; +import { usePathname, useRootNavigationState } from "expo-router"; +import { useStore } from "zustand"; +import { + useIsLastWorkspaceSelectionHydrated, + useLastWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; +import { createSettingsPresentation } from "./model"; + +const selection = createSettingsPresentation(); + +// Mount once above the sibling Settings routes so replacements and Back share +// one visit, and leaving Settings gives the next workspace entry a fresh default. +export function useSettingsRouteEntry(): void { + const pathname = usePathname(); + const navigationState = useRootNavigationState(); + const routeKey = navigationState?.routes[navigationState.index]?.key; + const workspace = useLastWorkspaceSelection(); + const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated(); + const workspaceServerId = workspace?.serverId ?? null; + useLayoutEffect(() => { + if (isWorkspaceHydrated && routeKey) { + selection.enterRoute({ key: routeKey, pathname }, workspaceServerId); + } + }, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]); +} + +export function useSettingsHostSelection(): string | null { + return useStore(selection.store, (state) => state.selectedServerId); +} + +export const selectSettingsHost = selection.selectHost; + +// Sibling routes remount the wide sidebar. Only the current mount owns its +// offset: RN Web can deliver its final scroll callback after unmount. +export function useSettingsSidebarScroll(enabled: boolean) { + const ref = useRef(null); + const owner = useRef | null>(null); + const restore = useCallback(() => { + if (owner.current) ref.current?.scrollTo({ y: owner.current.offset, animated: false }); + }, []); + useLayoutEffect(() => { + if (!enabled) return; + const sidebar = selection.attachSidebar(); + owner.current = sidebar; + restore(); + return () => { + sidebar.detach(); + owner.current = null; + }; + }, [enabled, restore]); + const onScroll = useCallback((event: NativeSyntheticEvent) => { + owner.current?.remember(event.nativeEvent.contentOffset.y); + }, []); + return { ref, onScroll, onContentSizeChange: restore, scrollEventThrottle: 16 }; +} diff --git a/packages/app/src/navigation/settings-selection/model.test.ts b/packages/app/src/navigation/settings-presentation/model.test.ts similarity index 69% rename from packages/app/src/navigation/settings-selection/model.test.ts rename to packages/app/src/navigation/settings-presentation/model.test.ts index 1ffbff8485..a8970b17b4 100644 --- a/packages/app/src/navigation/settings-selection/model.test.ts +++ b/packages/app/src/navigation/settings-presentation/model.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createSettingsSelection } from "./model"; +import { createSettingsPresentation } from "./model"; describe("Settings host selection", () => { it("keeps picker choices across sections, replacement and Back", () => { - const settings = createSettingsSelection(); + const settings = createSettingsPresentation(); settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); expect(settings.store.getState().selectedServerId).toBe("workspace-host"); settings.selectHost("chosen-host"); @@ -27,7 +27,7 @@ describe("Settings host selection", () => { }); it("applies explicit host links once, including project and plugin screens", () => { - const settings = createSettingsSelection(); + const settings = createSettingsPresentation(); settings.enterRoute( { key: "/settings/hosts/linked-host/projects/project", @@ -56,7 +56,7 @@ describe("Settings host selection", () => { }); it("seeds each new visit from the workspace, including a retained settings route", () => { - const settings = createSettingsSelection(); + const settings = createSettingsPresentation(); settings.enterRoute( { key: "/settings/general", pathname: "/settings/general" }, "first-workspace-host", @@ -77,7 +77,7 @@ describe("Settings host selection", () => { }); it("does not replay a retained host route after a newer picker choice", () => { - const settings = createSettingsSelection(); + const settings = createSettingsPresentation(); const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" }; settings.enterRoute(oldRoute, "workspace-host"); settings.selectHost("second"); @@ -93,10 +93,40 @@ describe("Settings host selection", () => { }); it("does not reseed a visit when remembered workspace state changes", () => { - const settings = createSettingsSelection(); + const settings = createSettingsPresentation(); settings.enterRoute({ key: "/settings", pathname: "/settings" }, null); settings.selectHost("chosen-host"); settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); expect(settings.store.getState().selectedServerId).toBe("chosen-host"); }); }); + +describe("Settings sidebar presentation", () => { + it("restores across replacements and rejects callbacks from older sidebars", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + const oldSidebar = settings.attachSidebar(); + oldSidebar.remember(246); + settings.enterRoute({ key: "host", pathname: "/settings/hosts/host/providers" }, "host"); + const currentSidebar = settings.attachSidebar(); + expect(currentSidebar.offset).toBe(246); + oldSidebar.remember(0); + oldSidebar.detach(); + expect(currentSidebar.offset).toBe(246); + currentSidebar.remember(190); + currentSidebar.detach(); + currentSidebar.remember(0); + expect(settings.attachSidebar().offset).toBe(190); + }); + + it("resets on leaving Settings and rejects callbacks from a previous visit", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + const previousVisit = settings.attachSidebar(); + previousVisit.remember(246); + settings.enterRoute({ key: "workspace", pathname: "/h/host/workspace/workspace" }, "host"); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + previousVisit.remember(246); + expect(settings.attachSidebar().offset).toBe(0); + }); +}); diff --git a/packages/app/src/navigation/settings-selection/model.ts b/packages/app/src/navigation/settings-presentation/model.ts similarity index 69% rename from packages/app/src/navigation/settings-selection/model.ts rename to packages/app/src/navigation/settings-presentation/model.ts index bedfb70626..2a88e9d82b 100644 --- a/packages/app/src/navigation/settings-selection/model.ts +++ b/packages/app/src/navigation/settings-presentation/model.ts @@ -9,10 +9,28 @@ interface SettingsSelectionState { selectedServerId: string | null; } -export function createSettingsSelection() { +export function createSettingsPresentation() { const store = createStore(() => ({ selectedServerId: null })); const enteredRoutes = new Map(); let inSettings = false; + let sidebarOffset = 0; + let sidebarOwner: object | null = null; + + function attachSidebar() { + const owner = {}; + sidebarOwner = owner; + return { + get offset() { + return sidebarOffset; + }, + remember(offset: number) { + if (sidebarOwner === owner) sidebarOffset = offset; + }, + detach() { + if (sidebarOwner === owner) sidebarOwner = null; + }, + }; + } function enterRoute( { key, pathname }: SettingsRouteEntry, @@ -20,6 +38,8 @@ export function createSettingsSelection() { ): void { const isSettings = pathname === "/settings" || pathname.startsWith("/settings/"); if (!isSettings) { + sidebarOffset = 0; + sidebarOwner = null; inSettings = false; enteredRoutes.clear(); return; @@ -42,5 +62,5 @@ export function createSettingsSelection() { store.setState({ selectedServerId: serverId }); } - return { store, enterRoute, selectHost }; + return { store, enterRoute, selectHost, attachSidebar }; } diff --git a/packages/app/src/navigation/settings-selection/index.ts b/packages/app/src/navigation/settings-selection/index.ts deleted file mode 100644 index dd4d57edd9..0000000000 --- a/packages/app/src/navigation/settings-selection/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useLayoutEffect } from "react"; -import { usePathname, useRootNavigationState } from "expo-router"; -import { useStore } from "zustand"; -import { - useIsLastWorkspaceSelectionHydrated, - useLastWorkspaceSelection, -} from "@/stores/navigation-active-workspace-store"; -import { createSettingsSelection } from "./model"; - -const selection = createSettingsSelection(); - -// Mount once above the sibling Settings routes so replacements and Back share -// one visit, and leaving Settings gives the next workspace entry a fresh default. -export function useSettingsRouteEntry(): void { - const pathname = usePathname(); - const navigationState = useRootNavigationState(); - const routeKey = navigationState?.routes[navigationState.index]?.key; - const workspace = useLastWorkspaceSelection(); - const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated(); - const workspaceServerId = workspace?.serverId ?? null; - useLayoutEffect(() => { - if (isWorkspaceHydrated && routeKey) { - selection.enterRoute({ key: routeKey, pathname }, workspaceServerId); - } - }, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]); -} - -export function useSettingsHostSelection(): string | null { - return useStore(selection.store, (state) => state.selectedServerId); -} - -export const selectSettingsHost = selection.selectHost; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 8cfa720245..10d87cdcba 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -133,7 +133,11 @@ import { type HostSectionSlug, type SettingsSectionSlug, } from "@/utils/host-routes"; -import { selectSettingsHost, useSettingsHostSelection } from "@/navigation/settings-selection"; +import { + selectSettingsHost, + useSettingsHostSelection, + useSettingsSidebarScroll, +} from "@/navigation/settings-presentation"; import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation"; import { isWeb } from "@/constants/platform"; @@ -945,6 +949,7 @@ function SettingsSidebar({ const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp)); const insets = useSafeAreaInsets(); const isDesktop = layout === "desktop"; + const sidebarScroll = useSettingsSidebarScroll(isDesktop); const outerContainerStyle = useMemo( () => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer], [isDesktop], @@ -1057,6 +1062,7 @@ function SettingsSidebar({ style={sidebarStyles.scrollBody} showsVerticalScrollIndicator={false} testID="settings-sidebar-scroll-body" + {...sidebarScroll} > {sidebarBody} @@ -1457,7 +1463,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti - + {content} diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index d403bfdfc3..78d2fa978e 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { chromium } from "playwright"; import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; +import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs"; import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs"; import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs"; @@ -455,17 +456,17 @@ function recordViewportMismatch(failures, label, actual, expected) { ); } -async function setWindowHidden(inspectorPort, hidden) { +async function evaluateDesktopWindow(inspectorPort, expression) { const [target] = await (await fetch(`http://127.0.0.1:${inspectorPort}/json/list`)).json(); const socket = new WebSocket(target.webSocketDebuggerUrl); try { - await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { socket.addEventListener("error", reject, { once: true }); socket.addEventListener("message", ({ data }) => { const response = JSON.parse(data); if (response.id !== 1) return; if (response.error || response.result?.exceptionDetails) reject(new Error(data)); - else resolve(); + else resolve(response.result.result.value); }); socket.addEventListener( "open", @@ -475,7 +476,8 @@ async function setWindowHidden(inspectorPort, hidden) { id: 1, method: "Runtime.evaluate", params: { - expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change'); })()`, + expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); ${expression} })()`, + returnByValue: true, }, }), ), @@ -487,6 +489,20 @@ async function setWindowHidden(inspectorPort, hidden) { } } +async function setWindowHidden(inspectorPort, hidden) { + await evaluateDesktopWindow( + inspectorPort, + `win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change');`, + ); +} + +async function resizeDesktopWindow(inspectorPort, { width, height }) { + return evaluateDesktopWindow( + inspectorPort, + `const [width, height] = win.getContentSize(); win.setContentSize(${width}, ${height}); return { width, height };`, + ); +} + async function verifyHiddenBrowserScreenshots({ page, client, @@ -1208,6 +1224,14 @@ async function main() { console.log("Plugin external links and workspace browser passed."); return; } + const settingsScroll = await runSettingsScrollRegression(page, (size) => + resizeDesktopWindow(inspectorPort, size), + ); + if (process.env.PASEO_DESKTOP_SETTINGS_SCROLL_ONLY === "1") { + writeJson(path.join(artifactDir, "result.json"), { settingsScroll }); + console.log("Desktop Settings scroll regression passed."); + return; + } const settingsMemory = await runSettingsMemoryRegression(page); if (process.env.PASEO_DESKTOP_SETTINGS_MEMORY_ONLY === "1") { writeJson(path.join(artifactDir, "result.json"), { settingsMemory }); diff --git a/packages/desktop/e2e/settings-scroll.electron.mjs b/packages/desktop/e2e/settings-scroll.electron.mjs new file mode 100644 index 0000000000..e72656db02 --- /dev/null +++ b/packages/desktop/e2e/settings-scroll.electron.mjs @@ -0,0 +1,68 @@ +import { expect } from "@playwright/test"; + +export async function runSettingsScrollRegression(page, resizeWindow) { + // Device-metrics emulation also pins later webview guests. Resize the real + // window so the following browser regression starts without an override. + const original = await resizeWindow({ width: 1000, height: 500 }); + const sidebar = () => page.locator('[data-testid="settings-sidebar-scroll-body"]:visible'); + const offset = () => sidebar().evaluate((node) => node.scrollTop); + const observations = []; + try { + await expect + .poll(() => page.evaluate(() => ({ width: innerWidth, height: innerHeight }))) + .toEqual({ width: 1000, height: 500 }); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await sidebar().hover(); + await page.mouse.wheel(0, 2000); + await expect.poll(offset).toBeGreaterThan(0); + const saved = await offset(); + for (const title of ["Providers", "Terminals", "Providers", "Terminals"]) { + const button = page + .locator('[data-testid="settings-sidebar"]:visible') + .getByRole("button", { name: title, exact: true }); + await expect(button).toBeInViewport(); + await button.click(); + await expect(page.locator('[data-testid="settings-detail-header-title"]:visible')).toHaveText( + title, + ); + await expect.poll(offset).toBe(saved); + observations.push({ title, offset: await offset() }); + } + const detail = page.locator('[data-testid="settings-detail-scroll-body"]:visible'); + await page.getByRole("button", { name: "Providers", exact: true }).click(); + await detail.hover(); + await page.mouse.wheel(0, 1000); + await expect.poll(() => detail.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + await expect.poll(offset).toBe(saved); + observations.push({ + independentDetailOffset: await detail.evaluate((node) => node.scrollTop), + sidebarOffset: await offset(), + }); + await sidebar().hover(); + await page.mouse.wheel(0, -2000); + await expect.poll(offset).toBe(0); + await page.mouse.wheel(0, 60); + await expect.poll(offset).toBe(60); + for (const title of ["Sidebar", "Appearance", "Sidebar"]) { + const button = page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: title, exact: true }); + await expect(button).toBeInViewport(); + await button.click(); + await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title); + await expect.poll(offset).toBe(60); + observations.push({ title, offset: await offset() }); + } + await page.locator('[data-testid="settings-back-to-workspace"]:visible').click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect.poll(offset).toBe(0); + observations.push({ reentryOffset: await offset() }); + await page.locator('[data-testid="settings-back-to-workspace"]:visible').click(); + return observations; + } finally { + await resizeWindow(original); + await expect + .poll(() => page.evaluate(() => ({ width: innerWidth, height: innerHeight }))) + .toEqual(original); + } +}