Files
kandev/apps/web/e2e/tests/settings/settings-discovery.spec.ts
T
2026-08-16 19:18:21 +00:00

166 lines
7.4 KiB
TypeScript

import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
const APPEARANCE_PATH = "/settings/preferences/appearance";
function settingsSidebar(page: Page) {
return page.getByTestId("app-sidebar-settings-mode");
}
function settingsResult(scope: Locator, label: string) {
return scope.getByRole("link").filter({ hasText: new RegExp(`^${label}`) });
}
test.describe("Settings discovery", () => {
test("gently reflows matching rows and respects reduced motion", async ({ testPage }) => {
await testPage.goto(APPEARANCE_PATH);
const sidebar = settingsSidebar(testPage);
const search = sidebar.getByRole("searchbox", { name: "Search settings" });
await search.fill("font");
const motionRows = sidebar.locator('[data-settings-search-motion-key^="item:"]');
await expect(motionRows).toHaveCount(2);
await waitForMotionToSettle(motionRows);
const animatedReorder = await changeSearchAndSampleMotion(search, "terminal font");
expect(animatedReorder.map((row) => row.key)).toEqual([
"item:terminal-font-family",
"item:terminal-font-size",
]);
// FLIP first paints the surviving rows at their old visual positions even though the DOM is
// already in ranked order. This inversion is the continuity we are testing.
expect(animatedReorder[0].top).toBeGreaterThan(animatedReorder[1].top);
expect(animatedReorder.every((row) => row.animations.includes(160))).toBe(true);
await waitForMotionToSettle(motionRows);
const settledTops = await motionRows.evaluateAll((rows) =>
rows.map((row) => row.getBoundingClientRect().top),
);
expect(settledTops[0]).toBeLessThan(settledTops[1]);
await testPage.emulateMedia({ reducedMotion: "reduce" });
await search.fill("font");
const reducedMotionReorder = await changeSearchAndSampleMotion(search, "terminal font");
expect(reducedMotionReorder.every((row) => row.animations.length === 0)).toBe(true);
});
test("filters the tree, reveals an exact control, and preserves browser history", async ({
testPage,
}) => {
await testPage.goto(APPEARANCE_PATH);
const sidebar = settingsSidebar(testPage);
const search = sidebar.getByRole("searchbox", { name: "Search settings" });
await expect(search).toBeVisible();
const [searchBox, navigationRowBox] = await Promise.all([
search.boundingBox(),
sidebar.getByRole("link", { name: "Appearance" }).boundingBox(),
]);
expect(searchBox).not.toBeNull();
expect(navigationRowBox).not.toBeNull();
expect(Math.abs(searchBox!.height - navigationRowBox!.height)).toBeLessThanOrEqual(2);
await search.fill("no such setting exists");
await expect(sidebar.getByText("No matching settings", { exact: true })).toBeVisible();
await search.press("Escape");
await expect(search).toHaveValue("");
await expect(sidebar.getByRole("link", { name: "Appearance" })).toBeVisible();
await search.fill("terminal font size");
const result = settingsResult(sidebar, "Terminal Font Size");
await expect(result).toBeVisible();
await expect(result).toContainText("Terminal & Editors");
await expect(sidebar.getByRole("link", { name: "Appearance" })).toHaveCount(0);
await result.click();
await expect(testPage).toHaveURL(
/\/settings\/preferences\/terminal-editors#setting-terminal-font-size$/,
);
const target = testPage.locator('[data-settings-target="setting-terminal-font-size"]');
await expect(target).toHaveAttribute("data-settings-target-highlight", "true");
await expect(testPage.getByTestId("terminal-font-size-input")).toBeFocused();
await testPage.goBack();
await expect(testPage).toHaveURL(new RegExp(`${APPEARANCE_PATH}$`));
await expect(
testPage.getByRole("heading", { level: 2, name: "Appearance", exact: true }),
).toBeVisible();
});
test("targets the current dirty page without opening the leave guard", async ({
testPage,
apiClient,
}) => {
const initial = await apiClient.getUserSettings();
const initialLayout = initial.settings.changes_panel_layout === "tree" ? "tree" : "flat";
const nextLayout = initialLayout === "tree" ? "Flat list" : "Tree";
await testPage.goto(APPEARANCE_PATH);
await testPage.getByTestId("changes-panel-layout-select").click();
await testPage.getByRole("option", { name: nextLayout }).click();
await expect(testPage.getByTestId("settings-floating-save")).toBeVisible();
const sidebar = settingsSidebar(testPage);
await sidebar.getByRole("searchbox", { name: "Search settings" }).fill("resource metrics");
await settingsResult(sidebar, "Resource Metrics").click();
await expect(testPage).toHaveURL(
/\/settings\/preferences\/appearance#setting-resource-metrics$/,
);
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
const target = testPage.locator('[data-settings-target="setting-resource-metrics"]');
await expect(target).toHaveAttribute("data-settings-target-highlight", "true");
expect(await target.evaluate((element) => element.contains(document.activeElement))).toBe(true);
await expect(testPage.getByTestId("settings-floating-save")).toBeVisible();
});
test("uses the existing leave guard for a dirty cross-page result", async ({
testPage,
apiClient,
}) => {
const initial = await apiClient.getUserSettings();
const initialLayout = initial.settings.changes_panel_layout === "tree" ? "tree" : "flat";
const nextLayout = initialLayout === "tree" ? "Flat list" : "Tree";
await testPage.goto(APPEARANCE_PATH);
await testPage.getByTestId("changes-panel-layout-select").click();
await testPage.getByRole("option", { name: nextLayout }).click();
const sidebar = settingsSidebar(testPage);
await sidebar.getByRole("searchbox", { name: "Search settings" }).fill("terminal font size");
await settingsResult(sidebar, "Terminal Font Size").click();
const guard = testPage.getByRole("alertdialog", { name: "Save changes before leaving?" });
await expect(guard).toBeVisible();
await guard.getByRole("button", { name: "Continue editing" }).click();
await expect(testPage).toHaveURL(new RegExp(`${APPEARANCE_PATH}$`));
await expect(testPage.getByTestId("settings-floating-save")).toBeVisible();
});
});
async function waitForMotionToSettle(rows: Locator) {
await rows.evaluateAll(async (elements) => {
await Promise.all(
elements.flatMap((element) => element.getAnimations().map((animation) => animation.finished)),
);
});
}
async function changeSearchAndSampleMotion(search: Locator, query: string) {
return search.evaluate(async (input, nextQuery) => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, nextQuery);
input.dispatchEvent(new Event("input", { bubbles: true }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
return [...document.querySelectorAll<HTMLElement>('[data-settings-search-motion-key^="item:"]')]
.map((row) => ({
key: row.dataset.settingsSearchMotionKey,
top: row.getBoundingClientRect().top,
animations: row.getAnimations().map((animation) => animation.effect?.getTiming().duration),
}))
.filter(
(row): row is { key: string; top: number; animations: Array<number | CSSNumericValue> } =>
Boolean(row.key),
);
}, query);
}