60d37aa24a
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
322 lines
14 KiB
TypeScript
322 lines
14 KiB
TypeScript
/**
|
|
* E2E: a plugin's own page is reachable from a phone.
|
|
*
|
|
* The desktop app sidebar (which renders `<PluginNavItems/>`) is
|
|
* `hidden md:block`, so before `MobilePluginNavSection` existed a
|
|
* `registerNavItem({ section: "main" })` entry had no phone entry point at
|
|
* all — the route worked, but only if you typed the URL. Settings > Plugins
|
|
* was always reachable through the settings menu sheet; this covers the
|
|
* plugin's *own* nav item.
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { Browser, BrowserContext, Page } from "@playwright/test";
|
|
import { expect, test } from "../../fixtures/test-base";
|
|
import { PrAssetCapture } from "../../helpers/pr-asset-capture";
|
|
import { holdPluginInstallResponse } from "../../helpers/plugin-install";
|
|
|
|
const PLUGIN_ID = "kandev-plugin-e2e";
|
|
const NAV_ITEM_ID = "e2e-hello";
|
|
const DESKTOP_VIEWPORT = { width: 1440, height: 900 };
|
|
const PACKAGE_PATH = path.resolve(
|
|
__dirname,
|
|
"../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz",
|
|
);
|
|
|
|
function pluginExecutablePath(installPath: string): string {
|
|
const serverDir = path.join(installPath, "server");
|
|
const executable = fs
|
|
.readdirSync(serverDir, { withFileTypes: true })
|
|
.find((entry) => entry.isFile())?.name;
|
|
if (!executable) throw new Error(`no plugin executable found under ${serverDir}`);
|
|
return path.join(serverDir, executable);
|
|
}
|
|
|
|
/** A desktop-sized client against the same backend, for the parity screenshot. */
|
|
async function openDesktopClient(
|
|
browser: Browser,
|
|
frontendUrl: string,
|
|
backendPort: number,
|
|
): Promise<{ page: Page; context: BrowserContext }> {
|
|
const context = await browser.newContext({
|
|
baseURL: frontendUrl,
|
|
viewport: DESKTOP_VIEWPORT,
|
|
});
|
|
const page = await context.newPage();
|
|
await page.addInitScript(
|
|
({ port }: { port: number }) => {
|
|
localStorage.setItem("kandev.onboarding.completed", "true");
|
|
window.__KANDEV_API_PORT = String(port);
|
|
},
|
|
{ port: backendPort },
|
|
);
|
|
return { page, context };
|
|
}
|
|
|
|
test.describe("Mobile plugin navigation", () => {
|
|
test.afterEach(async ({ apiClient }) => {
|
|
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
|
|
});
|
|
|
|
test("shows an install spinner while an upload install is pending on a phone", async ({
|
|
testPage,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
await testPage.goto("/settings/plugins");
|
|
await testPage.getByTestId("install-plugin-trigger").click();
|
|
const heldInstall = await holdPluginInstallResponse(testPage);
|
|
try {
|
|
await testPage.getByTestId("install-plugin-tab-upload").tap();
|
|
await testPage.getByTestId("install-plugin-file-input").setInputFiles(PACKAGE_PATH);
|
|
await testPage.getByTestId("install-plugin-upload-submit").tap();
|
|
await heldInstall.requestSeen;
|
|
|
|
const installButton = testPage.getByTestId("install-plugin-upload-submit");
|
|
await expect(installButton).toBeDisabled();
|
|
await expect(installButton).toHaveAttribute("aria-busy", "true");
|
|
await expect(installButton.locator(".animate-spin")).toBeVisible();
|
|
await expect(installButton).toHaveText(/Installing/);
|
|
} finally {
|
|
heldInstall.release();
|
|
if (heldInstall.requestStarted()) await heldInstall.responseSettled;
|
|
await testPage.unroute("**/api/plugins/install");
|
|
}
|
|
|
|
const installButton = testPage.getByTestId("install-plugin-upload-submit");
|
|
await expect(installButton).toBeEnabled();
|
|
await expect(installButton).toHaveText("Install");
|
|
await expect(testPage.getByTestId("install-plugin-error")).toContainText("install failed");
|
|
});
|
|
|
|
test("opens a plugin page from the phone menu sheet", async ({
|
|
testPage,
|
|
backend,
|
|
browser,
|
|
}, testInfo) => {
|
|
test.setTimeout(120_000);
|
|
const capture = new PrAssetCapture(testPage, testInfo.file);
|
|
|
|
// Settings > Plugins is reachable on a phone through the settings menu
|
|
// sheet — install the fixture through that same real flow.
|
|
await testPage.goto("/settings/plugins");
|
|
await testPage.getByTestId("install-plugin-trigger").click();
|
|
await testPage.getByTestId("install-plugin-tab-upload").click();
|
|
await testPage.getByTestId("install-plugin-file-input").setInputFiles(PACKAGE_PATH);
|
|
await testPage.getByTestId("install-plugin-upload-submit").click();
|
|
await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 30_000 });
|
|
|
|
await testPage.goto("/");
|
|
await testPage.reload();
|
|
|
|
// The desktop rail carries the same item but is hidden on a phone.
|
|
await expect(testPage.getByTestId("app-sidebar")).toBeHidden();
|
|
|
|
await testPage.getByRole("button", { name: "Open menu" }).click();
|
|
const navItem = testPage.getByTestId(`mobile-plugin-nav-item-${NAV_ITEM_ID}`);
|
|
await expect(navItem).toBeVisible();
|
|
await expect(navItem).toHaveText(/Hello E2E/);
|
|
expect((await navItem.boundingBox())?.height).toBeGreaterThanOrEqual(44);
|
|
|
|
// The sheet slides up on open and the section sits below the fold, so
|
|
// settle both before shooting or the asset captures an empty mid-animation
|
|
// drawer instead of the change under test.
|
|
await testPage.getByTestId("mobile-plugin-nav-section").scrollIntoViewIfNeeded();
|
|
await expect(navItem).toBeInViewport();
|
|
await capture.screenshot("mobile-plugin-nav-section", {
|
|
caption: "Phone menu sheet: the plugin's page now has an entry point",
|
|
});
|
|
|
|
await navItem.click();
|
|
await expect(testPage).toHaveURL(/\/plugins\/e2e-hello$/);
|
|
await expect(testPage.locator("#hello-plugin-page")).toBeVisible();
|
|
// Tapping the item also dismisses the sheet.
|
|
await expect(testPage.getByTestId("mobile-plugin-nav-section")).toBeHidden();
|
|
|
|
// A plugin route with default chrome brings no nav of its own, and the
|
|
// sidebar that normally owns Home is hidden below md — so the topbar must
|
|
// carry a phone escape route or the user is stranded here.
|
|
const home = testPage.getByTestId("topbar-phone-home");
|
|
await expect(home).toBeVisible();
|
|
await home.click();
|
|
// The crumb resolves through the navigation manifest, which keeps the
|
|
// active workspace instead of dropping to the workspace-less overview.
|
|
await expect(testPage).toHaveURL(/\/\?home=overview(&workspaceId=[^&]+)?$/);
|
|
|
|
// Desktop parity reference: the same item in the always-visible sidebar.
|
|
const desktop = await openDesktopClient(browser, backend.frontendUrl, backend.port);
|
|
await desktop.page.goto("/");
|
|
await desktop.page.waitForLoadState("networkidle");
|
|
await expect(desktop.page.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`)).toBeVisible();
|
|
await capture.screenshot("desktop-plugin-nav-sidebar", {
|
|
page: desktop.page,
|
|
caption: "Desktop, unchanged: the same nav item in the app sidebar",
|
|
});
|
|
await desktop.context.close();
|
|
|
|
capture.flush();
|
|
});
|
|
|
|
test("retries an errored plugin from the phone settings row", async ({
|
|
testPage,
|
|
apiClient,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
await testPage.goto("/settings/plugins");
|
|
await testPage.getByTestId("install-plugin-trigger").click();
|
|
await testPage.getByTestId("install-plugin-tab-upload").click();
|
|
await testPage.getByTestId("install-plugin-file-input").setInputFiles(PACKAGE_PATH);
|
|
await testPage.getByTestId("install-plugin-upload-submit").click();
|
|
|
|
const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
|
|
await expect(pluginRow).toBeVisible({ timeout: 30_000 });
|
|
const listResponse = await apiClient.rawRequest("GET", "/api/plugins");
|
|
const listBody = (await listResponse.json()) as {
|
|
plugins?: Array<{ id: string; install_path: string }>;
|
|
};
|
|
const installPath = listBody.plugins?.find((plugin) => plugin.id === PLUGIN_ID)?.install_path;
|
|
if (!installPath) throw new Error(`plugin ${PLUGIN_ID} install path was not returned`);
|
|
|
|
const executablePath = pluginExecutablePath(installPath);
|
|
const unavailablePath = `${executablePath}.unavailable`;
|
|
fs.renameSync(executablePath, unavailablePath);
|
|
try {
|
|
await backend.restart();
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const response = await apiClient.rawRequest("GET", `/api/plugins/${PLUGIN_ID}`);
|
|
if (!response.ok) return "missing";
|
|
return ((await response.json()) as { status?: string }).status ?? "missing";
|
|
},
|
|
{ timeout: 20_000, intervals: [250, 500, 1000] },
|
|
)
|
|
.toBe("error");
|
|
|
|
await testPage.goto("/settings/plugins");
|
|
await expect(pluginRow.getByText("Error", { exact: true })).toBeVisible({ timeout: 15_000 });
|
|
await expect(pluginRow.getByTestId(`plugin-error-${PLUGIN_ID}`)).toBeVisible();
|
|
await expect(pluginRow.getByRole("button", { name: "Enable" })).toBeVisible();
|
|
expect(
|
|
(await pluginRow.getByRole("button", { name: "Enable" }).boundingBox())?.height,
|
|
).toBeGreaterThanOrEqual(44);
|
|
|
|
fs.renameSync(unavailablePath, executablePath);
|
|
await pluginRow.getByRole("button", { name: "Enable" }).click();
|
|
await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible({ timeout: 15_000 });
|
|
await expect(pluginRow.getByTestId(`plugin-error-${PLUGIN_ID}`)).toHaveCount(0);
|
|
} finally {
|
|
if (fs.existsSync(unavailablePath) && !fs.existsSync(executablePath)) {
|
|
fs.renameSync(unavailablePath, executablePath);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("wraps a long plugin diagnostic without phone horizontal overflow", async ({
|
|
testPage,
|
|
apiClient,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
await testPage.goto("/settings/plugins");
|
|
await testPage.getByTestId("install-plugin-trigger").click();
|
|
await testPage.getByTestId("install-plugin-tab-upload").click();
|
|
await testPage.getByTestId("install-plugin-file-input").setInputFiles(PACKAGE_PATH);
|
|
await testPage.getByTestId("install-plugin-upload-submit").click();
|
|
|
|
const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
|
|
await expect(pluginRow).toBeVisible({ timeout: 30_000 });
|
|
const listResponse = await apiClient.rawRequest("GET", "/api/plugins");
|
|
const listBody = (await listResponse.json()) as {
|
|
plugins?: Array<{ id: string; install_path: string }>;
|
|
};
|
|
const installPath = listBody.plugins?.find((plugin) => plugin.id === PLUGIN_ID)?.install_path;
|
|
if (!installPath) throw new Error(`plugin ${PLUGIN_ID} install path was not returned`);
|
|
|
|
const executablePath = pluginExecutablePath(installPath);
|
|
const unavailablePath = `${executablePath}.unavailable`;
|
|
fs.renameSync(executablePath, unavailablePath);
|
|
const longDiagnostic = "X".repeat(2048);
|
|
|
|
try {
|
|
await backend.restart();
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const response = await apiClient.rawRequest("GET", `/api/plugins/${PLUGIN_ID}`);
|
|
if (!response.ok) return "missing";
|
|
return ((await response.json()) as { status?: string }).status ?? "missing";
|
|
},
|
|
{ timeout: 20_000, intervals: [250, 500, 1000] },
|
|
)
|
|
.toBe("error");
|
|
|
|
await testPage.route("**/api/plugins", async (route) => {
|
|
const response = await route.fetch();
|
|
const body = (await response.json()) as {
|
|
plugins?: Array<Record<string, unknown> & { id?: string }>;
|
|
};
|
|
body.plugins = body.plugins?.map((plugin) =>
|
|
plugin.id === PLUGIN_ID
|
|
? {
|
|
...plugin,
|
|
last_error: longDiagnostic,
|
|
last_error_at: "2026-08-02T12:34:56Z",
|
|
}
|
|
: plugin,
|
|
);
|
|
await route.fulfill({ response, json: body });
|
|
});
|
|
|
|
await testPage.reload();
|
|
const diagnostic = pluginRow.getByTestId(`plugin-error-${PLUGIN_ID}`);
|
|
await expect(diagnostic).toBeVisible({ timeout: 15_000 });
|
|
await expect(diagnostic.locator("span")).toHaveText(longDiagnostic);
|
|
|
|
const hasHorizontalOverflow = await testPage.evaluate(
|
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
|
);
|
|
expect(hasHorizontalOverflow).toBe(false);
|
|
} finally {
|
|
await testPage.unroute("**/api/plugins").catch(() => undefined);
|
|
if (fs.existsSync(unavailablePath) && !fs.existsSync(executablePath)) {
|
|
fs.renameSync(unavailablePath, executablePath);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("shows the sidebar-footer item in the Utilities group, not the Plugins group", async ({
|
|
testPage,
|
|
}) => {
|
|
test.setTimeout(60_000);
|
|
|
|
await testPage.goto("/settings/plugins");
|
|
await testPage.getByTestId("install-plugin-trigger").click();
|
|
await testPage.getByTestId("install-plugin-tab-upload").click();
|
|
await testPage.getByTestId("install-plugin-file-input").setInputFiles(PACKAGE_PATH);
|
|
await testPage.getByTestId("install-plugin-upload-submit").click();
|
|
await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 30_000 });
|
|
|
|
await testPage.goto("/");
|
|
await testPage.reload();
|
|
await testPage.getByRole("button", { name: "Open menu" }).click();
|
|
|
|
// Utilities rows carry no data-testid (see spec's Rendered identity
|
|
// section) — select by the visible label instead.
|
|
const utilitiesRow = testPage.getByRole("link", { name: "E2E Insights Tools" });
|
|
await expect(utilitiesRow).toBeVisible();
|
|
|
|
// Moves, does not add: the same item never also renders in the Plugins
|
|
// group.
|
|
const pluginsGroup = testPage.getByTestId("mobile-plugin-nav-section");
|
|
await expect(pluginsGroup.getByText("E2E Insights Tools")).toHaveCount(0);
|
|
|
|
// Complete the user path, not just the render: the row navigates.
|
|
await utilitiesRow.click();
|
|
await expect(testPage).toHaveURL(/\/plugins\/e2e-hello$/);
|
|
});
|
|
});
|