refactor(app/e2e): rewrite settings-navigation spec to zero raw locators (#726)

Replace 21 raw page.locator/getByTestId/getByText calls in the spec body
with named DSL operations in helpers/settings.ts. Each test body now reads
as prose: open settings, navigate to section, expect content.

New helpers: openCompactSettings, expectCompactSettingsList,
expectSettingsSidebarVisible/Hidden/Sections, goBackInSettings,
expectSettingsBackButton, clickSettingsBackToWorkspace,
verifyLegacyHostSettingsRedirect, openCompactSettingsHost,
expectHostSettingsUrl, expectAddHostMethodOptions, fillDirectHostUri,
expectDirectHostFormValues, expectDirectHostSslEnabled,
expectDirectHostUriValue/Hidden, expectDiagnosticsContent,
expectAboutContent, expectGeneralContent.

Export requireServerId from sidebar.ts so helpers encapsulate serverId
logic — spec has no direct env reads.

Also fix pre-existing typecheck error in use-keyboard-shortcuts.ts:
cast action.route to the expo-router Href type at call sites.
This commit is contained in:
Mohamed Boudra
2026-05-05 00:52:58 +08:00
parent fedf155efd
commit 685e86cffc
4 changed files with 203 additions and 150 deletions

View File

@@ -1,4 +1,9 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { requireServerId } from "./sidebar";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const SECTION_LABELS = { const SECTION_LABELS = {
general: "General", general: "General",
@@ -50,3 +55,113 @@ export async function selectHostConnectionType(
export async function toggleHostAdvanced(page: Page): Promise<void> { export async function toggleHostAdvanced(page: Page): Promise<void> {
await page.getByTestId("direct-host-advanced-toggle").click(); await page.getByTestId("direct-host-advanced-toggle").click();
} }
export async function openCompactSettings(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
export async function expectCompactSettingsList(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
}
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
export async function expectSettingsSidebarHidden(page: Page): Promise<void> {
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
}
export async function expectSettingsSidebarSections(
page: Page,
sections: Array<Exclude<SettingsSection, "projects">>,
): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
for (const section of sections) {
await expect(
sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }),
).toBeVisible();
}
}
export async function goBackInSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back", exact: true }).click();
}
export async function expectSettingsBackButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
}
export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
await page.getByTestId("settings-back-to-workspace").click();
}
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
}
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
const serverId = requireServerId();
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
await expectHostSettingsUrl(page, serverId);
}
export async function openCompactSettingsHost(page: Page): Promise<void> {
const serverId = requireServerId();
await openSettingsHost(page, serverId);
await expectHostSettingsUrl(page, serverId);
}
export async function expectAddHostMethodOptions(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
}
export async function fillDirectHostUri(page: Page, uri: string): Promise<void> {
await page.getByTestId("direct-host-uri-input").fill(uri);
}
export async function expectDirectHostFormValues(
page: Page,
fields: { host: string; port: string; password: string },
): Promise<void> {
await expect(page.getByTestId("direct-host-input")).toHaveValue(fields.host);
await expect(page.getByTestId("direct-port-input")).toHaveValue(fields.port);
await expect(page.getByTestId("direct-password-input")).toHaveValue(fields.password);
}
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
}
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
}
export async function expectDirectHostUriHidden(page: Page): Promise<void> {
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
}
export async function expectDiagnosticsContent(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
}
export async function expectAboutContent(page: Page): Promise<void> {
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
}
export async function expectGeneralContent(page: Page): Promise<void> {
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
}

View File

@@ -1,6 +1,6 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
function requireServerId(): string { export function requireServerId(): string {
const serverId = process.env.E2E_SERVER_ID; const serverId = process.env.E2E_SERVER_ID;
if (!serverId) { if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");

View File

@@ -1,62 +1,60 @@
import { test, expect, type Page } from "./fixtures"; import { test, expect } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app"; import { gotoAppShell, openSettings } from "./helpers/app";
import {
function escapeRegex(value: string): string { openSettingsSection,
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); expectSettingsHeader,
} openAddHostFlow,
selectHostConnectionType,
function getServerId(): string { toggleHostAdvanced,
const serverId = process.env.E2E_SERVER_ID; openCompactSettings,
if (!serverId) { expectCompactSettingsList,
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); expectSettingsSidebarVisible,
} expectSettingsSidebarHidden,
return serverId; expectSettingsSidebarSections,
} goBackInSettings,
expectSettingsBackButton,
async function clickSidebarSection(page: Page, label: string) { clickSettingsBackToWorkspace,
const sidebar = page.getByTestId("settings-sidebar"); verifyLegacyHostSettingsRedirect,
await expect(sidebar).toBeVisible(); openCompactSettingsHost,
await sidebar.getByRole("button", { name: label, exact: true }).click(); expectAddHostMethodOptions,
} fillDirectHostUri,
expectDirectHostFormValues,
expectDirectHostSslEnabled,
expectDirectHostUriValue,
expectDirectHostUriHidden,
expectDiagnosticsContent,
expectAboutContent,
expectGeneralContent,
} from "./helpers/settings";
test.describe("Settings sidebar navigation", () => { test.describe("Settings sidebar navigation", () => {
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => { test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await clickSidebarSection(page, "Diagnostics"); await openSettingsSection(page, "diagnostics");
await expect(page).toHaveURL(/\/settings\/diagnostics$/); await expectSettingsHeader(page, "Diagnostics");
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible(); await expectDiagnosticsContent(page);
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
await clickSidebarSection(page, "About"); await openSettingsSection(page, "about");
await expect(page).toHaveURL(/\/settings\/about$/); await expectSettingsHeader(page, "About");
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible(); await expectAboutContent(page);
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
await clickSidebarSection(page, "General"); await openSettingsSection(page, "general");
await expect(page).toHaveURL(/\/settings\/general$/); await expectSettingsHeader(page, "General");
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible(); await expectGeneralContent(page);
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
}); });
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => { test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page); await gotoAppShell(page);
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`); await verifyLegacyHostSettingsRedirect(page);
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
}); });
test("the + Add host button opens the add-host method modal", async ({ page }) => { test("the + Add host button opens the add-host method modal", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await openAddHostFlow(page);
await page.getByTestId("settings-add-host").click(); await expectAddHostMethodOptions(page);
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
}); });
test("direct connection advanced URI round-trips SSL and password into the form", async ({ test("direct connection advanced URI round-trips SSL and password into the form", async ({
@@ -64,38 +62,34 @@ test.describe("Settings sidebar navigation", () => {
}) => { }) => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await page.getByTestId("settings-add-host").click(); await toggleHostAdvanced(page);
await page.getByRole("button", { name: "Direct connection" }).click(); await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await toggleHostAdvanced(page);
await page.getByTestId("direct-host-advanced-toggle").click(); await expectDirectHostFormValues(page, {
await page host: "example.paseo.test",
.getByTestId("direct-host-uri-input") port: "7443",
.fill("tcp://example.paseo.test:7443?ssl=true&password=shared-secret"); password: "shared-secret",
await page.getByTestId("direct-host-advanced-toggle").click(); });
await expectDirectHostSslEnabled(page);
await expectDirectHostUriHidden(page);
await expect(page.getByTestId("direct-host-input")).toHaveValue("example.paseo.test"); await toggleHostAdvanced(page);
await expect(page.getByTestId("direct-port-input")).toHaveValue("7443"); await expectDirectHostUriValue(
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible(); page,
await expect(page.getByTestId("direct-password-input")).toHaveValue("shared-secret");
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret", "tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
); );
await page.getByTestId("direct-host-advanced-toggle").click(); await toggleHostAdvanced(page);
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0); await expectDirectHostUriHidden(page);
}); });
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => { test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await clickSettingsBackToWorkspace(page);
const backRow = page.getByTestId("settings-back-to-workspace");
await expect(backRow).toBeVisible();
await backRow.click();
await expect(page).not.toHaveURL(/\/settings(\/|$)/); await expect(page).not.toHaveURL(/\/settings(\/|$)/);
}); });
}); });
@@ -103,113 +97,57 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => { test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } }); test.use({ viewport: { width: 390, height: 844 } });
async function openCompactSettingsRoot(page: Page) {
await gotoAppShell(page);
// Wait for bootstrap to settle on a host route so storeReady is true before
// we navigate into the protected settings stack. A direct page.goto("/settings")
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
// Drive navigation the same way a user would: open the mobile drawer and tap
// the Settings footer icon. This preserves the in-app router state instead of
// triggering a full reload through Stack.Protected.
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(sidebarSettingsButton).toBeVisible();
await sidebarSettingsButton.click();
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
async function expectCompactSettingsRootList(page: Page) {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
}
test("/settings renders only the sidebar list (no section content)", async ({ page }) => { test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
await openCompactSettingsRoot(page); await gotoAppShell(page);
await openCompactSettings(page);
// Sidebar rows are present. await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
await expect( await expectCompactSettingsList(page);
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
).toBeVisible();
await expect(
page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "Diagnostics", exact: true }),
).toBeVisible();
await expect(
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
).toBeVisible();
// Section detail content is NOT rendered at the root. await expectSettingsBackButton(page);
await expectCompactSettingsRootList(page); await goBackInSettings(page);
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
await expect(rootBackButton).toBeVisible();
await rootBackButton.click();
await expect(page).not.toHaveURL(/\/settings(\/|$)/); await expect(page).not.toHaveURL(/\/settings(\/|$)/);
}); });
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => { test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
await openCompactSettingsRoot(page); await gotoAppShell(page);
await openCompactSettings(page);
await page await openSettingsSection(page, "diagnostics");
.getByTestId("settings-sidebar")
.getByRole("button", { name: "Diagnostics", exact: true })
.click();
await expect(page).toHaveURL(/\/settings\/diagnostics$/); await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible(); await expectDiagnosticsContent(page);
// Sidebar is no longer visible — we are on a detail screen. await expectSettingsSidebarHidden(page);
// (Expo Router stack keeps the previous screen in the DOM but hidden; check await expectSettingsBackButton(page);
// only visible instances.)
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
}); });
test("back from a section detail returns to the /settings list", async ({ page }) => { test("back from a section detail returns to the /settings list", async ({ page }) => {
await openCompactSettingsRoot(page); await gotoAppShell(page);
await openCompactSettings(page);
await page await openSettingsSection(page, "about");
.getByTestId("settings-sidebar")
.getByRole("button", { name: "About", exact: true })
.click();
await expect(page).toHaveURL(/\/settings\/about$/); await expect(page).toHaveURL(/\/settings\/about$/);
await page.getByRole("button", { name: "Back", exact: true }).click(); await goBackInSettings(page);
await expectCompactSettingsRootList(page); await expectCompactSettingsList(page);
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible(); await expectSettingsBackButton(page);
}); });
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => { test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
const serverId = getServerId(); await gotoAppShell(page);
await openCompactSettingsRoot(page); await openCompactSettings(page);
await page.getByTestId(`settings-host-entry-${serverId}`).click(); await openCompactSettingsHost(page);
await expect(page).toHaveURL( await expectSettingsBackButton(page);
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`), await expectSettingsSidebarHidden(page);
);
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
}); });
test("back from a host detail returns to the /settings list", async ({ page }) => { test("back from a host detail returns to the /settings list", async ({ page }) => {
const serverId = getServerId(); await gotoAppShell(page);
await openCompactSettingsRoot(page); await openCompactSettings(page);
await page.getByTestId(`settings-host-entry-${serverId}`).click(); await openCompactSettingsHost(page);
await expect(page).toHaveURL( await goBackInSettings(page);
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).toHaveURL(/\/settings$/); await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible(); await expectSettingsSidebarVisible(page);
}); });
}); });

View File

@@ -97,13 +97,13 @@ export function useKeyboardShortcuts({
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname }); navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true; return true;
case "router-replace": case "router-replace":
router.replace(action.route); router.replace(action.route as Parameters<typeof router.replace>[0]);
return true; return true;
case "router-back": case "router-back":
router.back(); router.back();
return true; return true;
case "router-push": case "router-push":
router.push(action.route); router.push(action.route as Parameters<typeof router.push>[0]);
return true; return true;
case "open-project-picker": case "open-project-picker":
void openProjectPickerAction(); void openProjectPickerAction();