Files
kandev/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts
T
João Salavisa 9885fd40ba fix(ui): polish mobile navigation and plugins (#3890)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-09-23 21:30:36 +01:00

266 lines
12 KiB
TypeScript

import path from "node:path";
import { waitForFiniteAnimations } from "../../helpers/animations";
import type { Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
import {
captureAppStatusBarSettings,
restoreAppStatusBarSettings,
type AppStatusBarSettingsBaseline,
} from "../../helpers/app-status-bar-settings";
import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions";
import { openTaskSession } from "../../helpers/session";
const PLUGIN_ID = "kandev-plugin-e2e";
const PACKAGE_PATH = path.resolve(
__dirname,
"../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz",
);
type SystemMetricsDisplayBaseline = {
show_in_topbar: boolean;
simplified?: boolean;
};
async function installFixture(page: Page) {
await page.goto("/settings/plugins");
await page.getByTestId("install-plugin-trigger").tap();
await page.getByTestId("install-plugin-tab-upload").tap();
await page.getByTestId("install-plugin-file-input").setInputFiles(PACKAGE_PATH);
await page.getByTestId("install-plugin-upload-submit").tap();
await expect(page.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 30_000 });
}
test.describe("Mobile plugin menu actions", () => {
let metricsBaseline: SystemMetricsDisplayBaseline;
let statusBarBaseline: AppStatusBarSettingsBaseline;
let createdTaskId: string | undefined;
test.beforeEach(async ({ apiClient, testPage }) => {
void testPage;
createdTaskId = undefined;
const settings = await apiClient.getUserSettings();
metricsBaseline = (settings.settings.system_metrics_display as
| SystemMetricsDisplayBaseline
| undefined) ?? { show_in_topbar: false };
statusBarBaseline = await captureAppStatusBarSettings(apiClient);
const response = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
app_status_bar_enabled: false,
system_metrics_display: { show_in_topbar: true, simplified: false },
});
expect(response.ok).toBe(true);
});
test.afterEach(async ({ apiClient }) => {
if (createdTaskId) await apiClient.deleteTask(createdTaskId).catch(() => undefined);
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
system_metrics_display: metricsBaseline,
});
await restoreAppStatusBarSettings(apiClient, statusBarBaseline);
});
test("keeps plugins, opted-in metrics, and native tools accessible in every mode", async ({
testPage,
}) => {
test.setTimeout(120_000);
await installFixture(testPage);
for (const [route, pluginPage] of [
["/?home=overview", "kanban"],
["/tasks", "tasks"],
["/threads", "kanban"],
]) {
await testPage.goto(route);
await expect(testPage.getByTestId("mobile-topbar-action-strip")).toHaveCount(0);
await expect(testPage.getByTestId("app-status-metrics")).toHaveCount(0);
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByRole("dialog", { name: "Menu", exact: true });
const plugin = menu.locator("#hello-main-top-bar");
const metrics = menu.getByTestId("app-status-metrics");
await expect(plugin).toHaveAccessibleName(`Hello ${pluginPage}`);
expect(
await plugin.evaluate((element) =>
Boolean(element.closest('[data-testid="mobile-plugin-nav-section"]')),
),
"Workspace controls belong to the labeled Plugins section",
).toBe(true);
expect(
await menu
.getByTestId("e2e-sidebar-workspace-actions")
.evaluate((element) =>
Boolean(element.closest('[data-testid="mobile-plugin-nav-section"]')),
),
"Sidebar plugin controls belong to the same Plugins section",
).toBe(true);
await expect(metrics).toBeVisible();
await expect(metrics.getByLabel(/^CPU /)).toBeVisible();
await waitForFiniteAnimations(menu);
const pluginSection = menu.getByTestId("mobile-plugin-nav-section");
const pluginBox = await requireBox(pluginSection, "Plugins section");
const metricsBox = await requireBox(metrics, "system metrics");
expect(metricsBox.y).toBeGreaterThanOrEqual(pluginBox.y + pluginBox.height);
await expect(pluginSection.getByRole("heading", { name: "Task", exact: true })).toHaveCount(
0,
);
for (const target of [
plugin,
menu.getByTestId("mobile-quick-chat-button"),
menu.getByTestId("mobile-quick-terminal-button"),
]) {
const box = await requireBox(target, "menu action");
expect(box.height).toBeGreaterThanOrEqual(44);
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.x + box.width).toBeLessThanOrEqual(testPage.viewportSize()!.width);
}
const icon = await requireBox(plugin.locator("svg").first(), "plugin icon");
expect(icon.width).toBeCloseTo(16, 0);
expect(icon.height).toBeCloseTo(16, 0);
await assertNoDocumentHorizontalOverflow(testPage, `menu tools on ${route}`);
await expect(menu.getByRole("textbox")).toHaveCount(0);
await testPage.keyboard.press("Escape");
if (route !== "/threads") {
await testPage.getByTestId("mobile-topbar-page-context").tap();
const options = testPage.getByRole("dialog", { name: "View options", exact: true });
await options.getByTestId("mobile-search-toggle").tap();
const search = testPage.getByTestId("mobile-search-bar");
await expect(menu).toHaveCount(0);
await expect(search.getByRole("textbox")).toBeFocused();
await assertNoDocumentHorizontalOverflow(testPage, "phone search");
}
await expect(testPage.getByTestId("app-status-metrics")).toHaveCount(0);
}
});
// @covers AC-UI-MOBILE-TASK-CHROME-001.7
test("moves session contributions from a long task header into the Plugins section", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
test.setTimeout(120_000);
await installFixture(testPage);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Move plug-in controls into the mobile menu without crowding",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
createdTaskId = task.id;
if (!task.session_id) throw new Error("Fixture task did not create a session");
await openTaskSession(testPage, task.id);
const fixedActions = testPage.getByTestId("mobile-topbar-actions");
const title = testPage.getByTestId("mobile-task-picker-trigger");
const menuTrigger = testPage.getByTestId("app-nav-trigger");
await expect(fixedActions.getByTestId("e2e-chat-top-bar-status")).toHaveCount(0);
await expect(fixedActions.getByTestId("e2e-chat-top-bar-action")).toHaveCount(0);
await expect(title).toContainText("Move plug-in controls");
const titleBox = await requireBox(title, "mobile task title");
const actionsBox = await requireBox(fixedActions, "mobile task actions");
expect(titleBox.width).toBeGreaterThanOrEqual(120);
expect(titleBox.x + titleBox.width).toBeLessThanOrEqual(actionsBox.x);
const triggerBox = await requireBox(menuTrigger, "mobile menu trigger");
expect(triggerBox.width).toBeGreaterThanOrEqual(44);
expect(triggerBox.height).toBeGreaterThanOrEqual(44);
await assertNoDocumentHorizontalOverflow(testPage, "task header with plugin contributions");
await menuTrigger.tap();
const menu = testPage.getByRole("dialog", { name: "Menu", exact: true });
const pluginSection = menu.getByTestId("mobile-plugin-nav-section");
const status = pluginSection.getByTestId("e2e-chat-top-bar-status");
const action = pluginSection.getByTestId("e2e-chat-top-bar-action");
await expect(pluginSection).toBeVisible();
await pluginSection.scrollIntoViewIfNeeded();
await waitForFiniteAnimations(menu);
await expect(menu.getByText("Plugins", { exact: true })).toHaveCount(1);
await expect(pluginSection.getByRole("heading", { name: "Workspace" })).toBeVisible();
await expect(pluginSection.getByRole("heading", { name: "Task", exact: true })).toBeVisible();
await expect(pluginSection.locator("#hello-main-top-bar")).toHaveCount(1);
const workspaceAction = pluginSection.getByTestId("e2e-sidebar-workspace-actions");
await expect(workspaceAction).toHaveCount(1);
await workspaceAction.tap();
await expect(workspaceAction).toHaveAttribute("data-clicked", "true");
await expect(menu.locator("nav.overflow-y-auto")).toHaveCount(1);
const metrics = menu.getByTestId("app-status-metrics");
await expect(metrics.getByLabel(/^CPU /)).toBeVisible();
const metricsBox = await requireBox(metrics, "system metrics");
const pluginBox = await requireBox(pluginSection, "Plugins section");
expect(metricsBox.y).toBeGreaterThanOrEqual(pluginBox.y + pluginBox.height);
await expect(status).toHaveAttribute("data-task-id", task.id);
await expect(status).toHaveAttribute("data-workspace-id", seedData.workspaceId);
await expect(status).toHaveAttribute("data-active-session-id", task.session_id);
await expect(status).toHaveAttribute("data-session-ids", task.session_id);
await expect(status).toHaveAttribute("data-presentation", "mobile");
await expect(action).toHaveAttribute("data-presentation", "mobile");
for (const contribution of [status, action]) {
const box = await requireBox(contribution, "session plugin contribution");
const menuBox = await requireBox(menu, "mobile menu");
expect(box.x).toBeGreaterThanOrEqual(menuBox.x);
expect(box.x + box.width).toBeLessThanOrEqual(menuBox.x + menuBox.width);
}
const actionBox = await requireBox(action, "session plugin action");
expect(actionBox.height).toBeGreaterThanOrEqual(44);
expect(actionBox.width).toBeGreaterThanOrEqual(44);
await action.tap();
await expect(action).toHaveAttribute("data-activated", "true");
await expect(menu).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "task plugin menu");
for (const width of [393, 320, 767]) {
await testPage.setViewportSize({ width, height: 851 });
await pluginSection.evaluate((element) => element.scrollIntoView({ block: "start" }));
const sectionBox = await requireBox(pluginSection, "Plugins section");
for (const control of [
action,
workspaceAction,
pluginSection.locator("#hello-main-top-bar"),
]) {
const box = await requireBox(control, "plugin control");
expect(box.height).toBeGreaterThanOrEqual(44);
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.x).toBeGreaterThanOrEqual(sectionBox.x);
expect(box.x + box.width).toBeLessThanOrEqual(sectionBox.x + sectionBox.width);
}
await assertNoDocumentHorizontalOverflow(testPage, `plugin menu at ${width}px`);
if (width === 393) {
for (const colorScheme of ["dark", "light"] as const) {
await testPage.emulateMedia({ colorScheme });
await waitForFiniteAnimations(menu);
await prCapture.screenshot(`mobile-task-plugin-menu-${colorScheme}`, {
caption: `Workspace and task plugins grouped together, followed by resources (${colorScheme})`,
});
}
}
}
await testPage.keyboard.press("Escape");
await expect(menu).toBeHidden();
await expect(menuTrigger).toBeFocused();
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
app_status_bar_enabled: true,
});
await testPage.reload();
await testPage.getByTestId("app-nav-trigger").tap();
await expect(menu.getByTestId("mobile-plugin-nav-section")).toBeVisible();
await expect(menu.getByTestId("app-status-metrics")).toHaveCount(0);
if (prCapture.capturing) {
await testPage.setViewportSize({ width: 393, height: 851 });
await testPage.goto("/?home=overview");
await testPage.getByTestId("app-nav-trigger").tap();
const listingPlugins = menu.getByTestId("mobile-plugin-nav-section");
await expect(listingPlugins).toBeVisible();
await listingPlugins.evaluate((element) => element.scrollIntoView({ block: "start" }));
await waitForFiniteAnimations(menu);
await prCapture.screenshot("mobile-listing-plugin-menu", {
caption:
"Listing menu with a single plugin group; resources are available through Status when enabled",
});
}
});
});