Files
paseo/packages/app/e2e/support/helpers/sidebar-nav-settings.ts
Mohamed Boudra 7fd469ae19 Enable plugin registry installs by default (#6224)
* feat(plugins): enable registry installs by default

* fix(plugins): explain explicit Git source for missing registry IDs

* fix(plugins): tailor missing registry guidance to installation

* test(app): sample footer geometry in one browser frame

* fix(plugins): make registry source resolution deterministic

Directory existence must not change the meaning of a registry identifier shared by CLI, RPC, and UI installs. Classify explicit host paths in the source parser and explain recovery from registry transport and revision-selection failures.

* fix(plugins): recognize dot and Windows directory sources

* test(cli): wait for readable daemon ownership before cancellation

The cancellation test raced PID-file creation against its first write and parsed empty JSON in CI. Use the public daemon-instance reader to wait for and capture the supervisor PID before asserting cancellation cleanup.

* test(app): expect missing directory install guidance
2026-10-06 16:52:02 +01:00

301 lines
10 KiB
TypeScript

import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings";
const APP_SETTINGS_KEY = "@paseo:app-settings";
/** Persisted nav key -> the testID the app shell renders that item with. */
const SHELL_ROW_TEST_IDS = {
"new-workspace": "sidebar-global-new-workspace",
history: "sidebar-sessions",
search: "sidebar-search",
schedules: "sidebar-schedules",
} as const;
export type SidebarNavKey = keyof typeof SHELL_ROW_TEST_IDS;
export interface SidebarNavPreference {
key: string;
visible: boolean;
}
function shellRow(page: Page, key: SidebarNavKey): Locator {
// `:visible` rather than a plain testID: the shell keeps a compact copy of the
// sidebar mounted, so the pinned row is the first visible match.
return page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`).first();
}
function settingsRow(page: Page, key: SidebarNavKey): Locator {
return page.getByTestId(`sidebar-nav-item-${key}`);
}
function itemLabel(key: SidebarNavKey): string {
return {
"new-workspace": "New workspace",
history: "History",
search: "Search",
schedules: "Schedules",
}[key];
}
async function rowTop(locator: Locator): Promise<number | null> {
const box = await locator.boundingBox();
return box?.y ?? null;
}
export async function seedSidebarNavPreferences(
page: Page,
preferences: SidebarNavPreference[],
): Promise<void> {
await page.addInitScript(
({ key, sidebarNavItems }) => {
localStorage.setItem(key, JSON.stringify({ sidebarNavItems }));
},
{ key: APP_SETTINGS_KEY, sidebarNavItems: preferences },
);
}
/** Seeds stored footer rows once; a reload keeps whatever the app wrote since. */
export async function seedSidebarFooterPreferences(
page: Page,
preferences: SidebarNavPreference[],
): Promise<void> {
await page.addInitScript(
({ key, sidebarFooterItems }) => {
if (localStorage.getItem(key)) return;
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
},
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
);
}
export async function openSidebarNavSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "sidebar");
await expect(page.getByTestId("sidebar-nav-section-header")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("sidebar-nav-section-footer")).toBeVisible();
}
export async function leaveSettings(page: Page): Promise<void> {
await clickSettingsBackToWorkspace(page);
}
export async function moveSidebarNavItemUp(page: Page, key: SidebarNavKey): Promise<void> {
await settingsRow(page, key).getByRole("button", { name: "Move up", exact: true }).click();
}
export async function setSidebarNavItemVisible(
page: Page,
key: SidebarNavKey,
visible: boolean,
): Promise<void> {
const toggle = settingsRow(page, key).getByRole("switch", {
name: itemLabel(key),
exact: true,
});
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
}
export async function expectSidebarNavSettingsRow(
page: Page,
expected: { key: SidebarNavKey; label: string; visible: boolean },
): Promise<void> {
const row = settingsRow(page, expected.key);
await expect(row).toBeVisible();
await expect(row.getByText(expected.label, { exact: true })).toBeVisible();
const toggle = row.getByRole("switch", { name: expected.label, exact: true });
await expect(toggle).toHaveAccessibleName(expected.label);
await expect(toggle).toHaveAttribute("aria-checked", String(expected.visible));
}
export async function expectSidebarNavSettingsOrder(
page: Page,
keys: SidebarNavKey[],
): Promise<void> {
await expectVerticalOrder(keys, (key) => settingsRow(page, key), "sidebar nav settings rows");
}
export async function expectSidebarOrder(page: Page, keys: SidebarNavKey[]): Promise<void> {
await expectVerticalOrder(keys, (key) => shellRow(page, key), "app shell sidebar rows");
}
export async function expectSidebarItemHidden(page: Page, key: SidebarNavKey): Promise<void> {
await expect(page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`)).toHaveCount(0);
}
export async function expectStoredSidebarNav(
page: Page,
expected: SidebarNavPreference[],
): Promise<void> {
await expect
.poll(
() =>
page.evaluate((key) => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw).sidebarNavItems ?? null) : null;
}, APP_SETTINGS_KEY),
{ timeout: 15_000 },
)
.toEqual(expected);
}
async function expectVerticalOrder<Key extends string>(
keys: Key[],
locate: (key: Key) => Locator,
subject: string,
): Promise<void> {
await expect
.poll(
async () => {
const measured = await Promise.all(
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
);
if (!measured.every((entry): entry is { key: Key; top: number } => entry.top !== null))
return null;
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
},
{ message: `Expected ${subject} in order`, timeout: 15_000 },
)
.toEqual(keys);
}
/** Persisted footer row key -> the testID the app shell renders that row with. */
function shellFooterTestID(key: string): string {
if (key === "usage") return "sidebar-usage";
const [, pluginId, itemId] = key.split(":");
return `plugin-sidebar-footer-${pluginId}-${itemId}`;
}
function shellFooterRow(page: Page, key: string): Locator {
return page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`).first();
}
function footerSettingsRows(page: Page): Locator {
return page
.getByTestId("sidebar-nav-section-footer")
.locator('[data-testid^="sidebar-nav-item-"]');
}
export async function expectFooterSettingsKeys(page: Page, keys: string[]): Promise<void> {
await expect
.poll(async () =>
(
await footerSettingsRows(page).evaluateAll((rows) =>
rows.map((row) => row.getAttribute("data-testid")),
)
).map((testID) => testID?.replace("sidebar-nav-item-", "")),
)
.toEqual(keys);
}
export async function moveFooterItemUp(page: Page, key: string): Promise<void> {
await page
.getByTestId("sidebar-nav-section-footer")
.getByTestId(`sidebar-nav-move-up-${key}`)
.click();
}
function footerItemSwitch(page: Page, key: string): Locator {
return page.getByTestId("sidebar-nav-section-footer").getByTestId(`sidebar-nav-toggle-${key}`);
}
export async function setFooterItemVisible(
page: Page,
key: string,
visible: boolean,
): Promise<void> {
const toggle = footerItemSwitch(page, key);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
}
/** Whether Settings > Sidebar shows a footer item as on. */
export async function expectFooterItemSetting(
page: Page,
key: string,
visible: boolean,
): Promise<void> {
await expect(footerItemSwitch(page, key)).toHaveAttribute("aria-checked", String(visible));
}
export async function expectFooterOrder(page: Page, keys: string[]): Promise<void> {
await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows");
}
export async function expectFooterItemHidden(page: Page, key: string): Promise<void> {
await expect(page.locator(`[data-testid="${shellFooterTestID(key)}"]:visible`)).toHaveCount(0);
}
const FOOTER_ICON_TEST_IDS = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
];
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
const FOOTER_GEOMETRY_TOLERANCE = 0.01;
/**
* One line of same-size icons: Add project, Usage and Hosts together on the left, Help and
* Settings together at the end.
*/
export async function expectFooterIconRow(page: Page): Promise<void> {
// Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
const boxes = await page
.locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
.evaluateAll((elements) =>
elements.map((element) => {
const { x, y, width, height } = element.getBoundingClientRect();
return { testID: element.getAttribute("data-testid"), x, y, width, height };
}),
);
expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
const [first] = boxes;
for (const box of boxes) {
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
expect(Math.abs(box.width - first!.width)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE);
}
const gaps = boxes.slice(1).map((box, index) => box.x - (boxes[index]!.x + boxes[index]!.width));
for (const index of [0, 1, 3]) {
expect(Math.abs(gaps[index]!)).toBeLessThan(FOOTER_GEOMETRY_TOLERANCE);
}
expect(gaps[2]).toBeGreaterThan(first!.width);
}
export async function expectFooterSeparator(page: Page, shown: boolean): Promise<void> {
await expect(page.locator('[data-testid="sidebar-footer"]:visible')).toHaveCSS(
"border-top-width",
"1px",
);
await expect(page.locator('[data-testid="sidebar-footer-bottom-line"]:visible')).toHaveCSS(
"border-top-width",
"0px",
);
const separator = page.locator('[data-testid="sidebar-footer-separator"]:visible');
await expect(separator).toHaveCount(shown ? 1 : 0);
expect(
await separator.evaluateAll((elements) =>
elements.map((element) => getComputedStyle(element).borderBottomWidth),
),
).toEqual(shown ? ["1px"] : []);
}
export async function hoverFooterAddProject(page: Page): Promise<void> {
await page.locator('[data-testid="sidebar-add-project"]:visible').hover();
const tooltip = page.getByTestId("sidebar-add-project-tooltip");
await expect(tooltip.getByText("Add project", { exact: true })).toBeVisible();
await expect(tooltip.getByText("Ctrl+O", { exact: true })).toBeVisible();
}
export async function footerScreenshot(page: Page, name: string): Promise<void> {
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
if (!directory) return;
await page.waitForTimeout(600);
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
await page.screenshot({ path: path.join(directory, `${name}.png`) });
}