mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
fix(app): keep your place in the Settings sidebar (#7899)
Keep sidebar scroll position for the Settings visit across app and host section navigation. Reject stale scroll callbacks from replaced sidebars and reset the offset when leaving Settings. Preserve the existing sibling routes and host-selection behavior. Consolidate the restoration work and broader regression coverage from the predecessor pull requests. Closes #1532. Co-authored-by: bingqilinweimaotai <111987281+bingqilinweimaotai@users.noreply.github.com> Co-authored-by: shampoo <wn990916@gmail.com>
This commit is contained in:
co-authored by
bingqilinweimaotai
shampoo
parent
ff31b6e5b9
commit
5e53bb78c2
@@ -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,
|
||||
|
||||
@@ -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<number> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
await page.getByTestId("settings-sidebar-scroll-body").hover();
|
||||
await page.mouse.wheel(0, -pixels);
|
||||
}
|
||||
|
||||
export async function changeSettingsInterfaceFontSize(page: Page, size: number): Promise<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<ScrollView>(null);
|
||||
const owner = useRef<ReturnType<typeof selection.attachSidebar> | 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<NativeScrollEvent>) => {
|
||||
owner.current?.remember(event.nativeEvent.contentOffset.y);
|
||||
}, []);
|
||||
return { ref, onScroll, onContentSizeChange: restore, scrollEventThrottle: 16 };
|
||||
}
|
||||
+36
-6
@@ -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);
|
||||
});
|
||||
});
|
||||
+22
-2
@@ -9,10 +9,28 @@ interface SettingsSelectionState {
|
||||
selectedServerId: string | null;
|
||||
}
|
||||
|
||||
export function createSettingsSelection() {
|
||||
export function createSettingsPresentation() {
|
||||
const store = createStore<SettingsSelectionState>(() => ({ selectedServerId: null }));
|
||||
const enteredRoutes = new Map<string, string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
</ScrollView>
|
||||
@@ -1457,7 +1463,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
</WindowChromeRegion>
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
|
||||
<PageLayout title={detailTitle} titleTestID="settings-detail-header-title">
|
||||
<PageLayout
|
||||
title={detailTitle}
|
||||
titleTestID="settings-detail-header-title"
|
||||
testID="settings-detail-scroll-body"
|
||||
>
|
||||
{content}
|
||||
</PageLayout>
|
||||
</View>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user