fix: hide sidebar footer urls on hover (#1348)

This commit is contained in:
Carlos Florêncio
2026-06-12 09:50:35 +01:00
committed by GitHub
parent ede3e20300
commit 7eec66836b
10 changed files with 351 additions and 71 deletions
@@ -0,0 +1,102 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
routerPush: vi.fn(),
toggleSettingsMode: vi.fn(),
}));
const state = {
workspaces: { activeId: "ws-1" as string | null },
appSidebar: { settingsMode: false },
toggleAppSidebarSettingsMode: mocks.toggleSettingsMode,
};
let officeEnabled = false;
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mocks.routerPush }),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
}));
vi.mock("@/hooks/domains/features/use-feature", () => ({
useFeature: () => officeEnabled,
}));
vi.mock("@/hooks/use-release-notes", () => ({
useReleaseNotes: () => ({
unseenEntries: [],
latestVersion: "0.0.0",
hasUnseen: false,
dialogOpen: false,
openDialog: vi.fn(),
closeDialog: vi.fn(),
hasNotes: false,
showTopbarButton: false,
}),
}));
vi.mock("@/components/improve-kandev-dialog", () => ({
ImproveKandevDialog: () => null,
}));
vi.mock("@/components/release-notes/release-notes-dialog", () => ({
ReleaseNotesDialog: () => null,
}));
vi.mock("@/components/theme-toggle", () => ({
ThemeToggle: () => <button type="button">Theme</button>,
}));
import { AppSidebarFooter } from "./app-sidebar-footer";
function renderFooter() {
return render(
<TooltipProvider>
<AppSidebarFooter collapsed={false} />
</TooltipProvider>,
);
}
describe("AppSidebarFooter", () => {
beforeEach(() => {
officeEnabled = false;
state.appSidebar.settingsMode = false;
mocks.routerPush.mockClear();
mocks.toggleSettingsMode.mockClear();
});
afterEach(() => cleanup());
it("renders navigation icons as buttons so hover does not expose link URLs", () => {
officeEnabled = true;
renderFooter();
const statsButton = screen.getByRole("button", { name: "Stats" });
const officeButton = screen.getByRole("button", { name: "Office" });
expect(statsButton).toBeTruthy();
expect(officeButton).toBeTruthy();
expect(statsButton.getAttribute("href")).toBeNull();
expect(officeButton.getAttribute("href")).toBeNull();
expect(screen.queryByRole("link", { name: "Stats" })).toBeNull();
expect(screen.queryByRole("link", { name: "Office" })).toBeNull();
});
it("navigates from the Stats and Office footer buttons", () => {
officeEnabled = true;
renderFooter();
fireEvent.click(screen.getByRole("button", { name: "Stats" }));
fireEvent.click(screen.getByRole("button", { name: "Office" }));
expect(mocks.routerPush).toHaveBeenNthCalledWith(1, "/stats");
expect(mocks.routerPush).toHaveBeenNthCalledWith(2, "/office");
});
});
@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
IconBuildings,
@@ -31,7 +30,6 @@ type FooterIconButtonProps = {
label: string;
collapsed: boolean;
onClick?: () => void;
href?: string;
badge?: boolean;
testId?: string;
/** Toggle state: rotates the icon a half-turn (spins back out when cleared). */
@@ -43,7 +41,6 @@ function FooterIconButton({
label,
collapsed,
onClick,
href,
badge,
testId,
active,
@@ -68,14 +65,9 @@ function FooterIconButton({
</>
);
const trigger = href ? (
<Button asChild {...buttonProps}>
<Link href={href} aria-label={label} data-testid={testId}>
{content}
</Link>
</Button>
) : (
const trigger = (
<Button
type="button"
onClick={onClick}
{...buttonProps}
aria-label={label}
@@ -122,7 +114,7 @@ export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) {
icon={IconChartBar}
label="Stats"
collapsed={collapsed}
href="/stats"
onClick={() => router.push("/stats")}
testId="sidebar-stats-button"
/>
<FooterIconButton
@@ -147,7 +139,7 @@ export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) {
icon={IconBuildings}
label="Office"
collapsed={collapsed}
href="/office"
onClick={() => router.push("/office")}
testId="sidebar-office-button"
/>
)}
@@ -275,7 +275,7 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
style={{ minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT }}
>
<div className="h-full w-full p-2 pb-3">
<div ref={terminalRef} className="h-full w-full" />
<div ref={terminalRef} data-testid="terminal-xterm-host" className="h-full w-full" />
</div>
<TerminalSearchBar search={search} />
{!isConnected && (
+36
View File
@@ -995,6 +995,25 @@ export class ApiClient {
await this.request("POST", "/api/v1/github/mock/task-prs", data);
}
async getTaskPR(taskId: string): Promise<{
task_id: string;
pr_number: number;
state: string;
review_state: string;
checks_state: string;
mergeable_state: string;
review_count: number;
pending_review_count: number;
required_reviews?: number | null;
} | null> {
const res = await this.rawRequest("GET", `/api/v1/github/task-prs/${taskId}`);
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`getTaskPR failed (${res.status}): ${await res.text()}`);
}
return res.json();
}
async mockGitHubSeedPRFeedback(data: {
owner: string;
repo: string;
@@ -1118,6 +1137,23 @@ export class ApiClient {
return this.request("GET", `/api/v1/tasks/${taskId}`);
}
async getTaskPlan(taskId: string): Promise<{
task_id: string;
content: string;
title?: string;
created_by: string;
updated_at: string;
} | null> {
try {
return await this.wsRequest("task.plan.get", { task_id: taskId });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Backend returns "plan not found" when no plan exists for the task.
if (message.includes("plan not found")) return null;
throw error;
}
}
/**
* Send a one-shot WebSocket request and await the matching response.
* Used for actions exposed only over WS (e.g. session.launch). Opens a
@@ -30,6 +30,36 @@ function planTabIndicator(page: Page) {
return page.getByTestId("plan-tab-indicator");
}
async function waitForAgentPlan(apiClient: ApiClient, taskId: string, contentText: string) {
await expect
.poll(
async () => {
const plan = await apiClient.getTaskPlan(taskId);
return plan?.created_by === "agent" && plan.content.includes(contentText);
},
{
timeout: 15_000,
message: `Expected agent-authored plan containing "${contentText}"`,
},
)
.toBe(true);
}
async function expectPlanIndicatorVisible(page: Page, session: SessionPage) {
await planTabIndicator(page)
.waitFor({ state: "visible", timeout: 5_000 })
.catch(async () => {
// The plan is durable backend state, but the tab indicator is armed from
// the task.plan.* WS push. Under shard load that push can beat the page's
// subscription; a reload exercises the same self-heal users get on refresh.
await page.reload();
await session.waitForLoad();
});
await expect(planTabLocator(page)).toBeVisible({ timeout: 15_000 });
await expect(planTabIndicator(page)).toBeVisible({ timeout: 15_000 });
}
async function seedTaskAndWaitForIdle(
testPage: Page,
apiClient: ApiClient,
@@ -67,13 +97,14 @@ test.describe("Plan panel auto-open + indicator", () => {
}) => {
test.setTimeout(90_000);
const { session } = await seedTaskAndWaitForIdle(
const { session, taskId } = await seedTaskAndWaitForIdle(
testPage,
apiClient,
seedData,
"plan indicator create",
CREATE_PLAN_SCRIPT,
);
await waitForAgentPlan(apiClient, taskId, "Step one");
// Plan tab is rendered (panel mounted as a sibling of chat in the center group)
await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 });
@@ -87,7 +118,7 @@ test.describe("Plan panel auto-open + indicator", () => {
// `task.plan.created` WS push, or the eager getTaskPlan self-heal if the
// push was missed. Both can land after the default 5s under shard load, so
// match the 15s tab budget.
await expect(planTabIndicator(testPage)).toBeVisible({ timeout: 15_000 });
await expectPlanIndicatorVisible(testPage, session);
});
test("clicking the Plan tab clears the indicator and reveals plan content", async ({
@@ -97,15 +128,16 @@ test.describe("Plan panel auto-open + indicator", () => {
}) => {
test.setTimeout(90_000);
const { session } = await seedTaskAndWaitForIdle(
const { session, taskId } = await seedTaskAndWaitForIdle(
testPage,
apiClient,
seedData,
"plan indicator acknowledge",
CREATE_PLAN_SCRIPT,
);
await waitForAgentPlan(apiClient, taskId, "Step one");
await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 });
await expect(planTabIndicator(testPage)).toBeVisible({ timeout: 15_000 });
await expectPlanIndicatorVisible(testPage, session);
await session.clickTab("Plan");
@@ -122,15 +154,16 @@ test.describe("Plan panel auto-open + indicator", () => {
}) => {
test.setTimeout(120_000);
const { session } = await seedTaskAndWaitForIdle(
const { session, taskId } = await seedTaskAndWaitForIdle(
testPage,
apiClient,
seedData,
"plan indicator update",
CREATE_PLAN_SCRIPT,
);
await waitForAgentPlan(apiClient, taskId, "Step one");
await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 });
await expect(planTabIndicator(testPage)).toBeVisible({ timeout: 15_000 });
await expectPlanIndicatorVisible(testPage, session);
// Acknowledge then leave back to chat
await session.clickTab("Plan");
@@ -141,10 +174,11 @@ test.describe("Plan panel auto-open + indicator", () => {
// Trigger an agent update via a follow-up message
await session.sendMessage(UPDATE_PLAN_SCRIPT);
await expect(session.idleInput()).toBeVisible({ timeout: 45_000 });
await waitForAgentPlan(apiClient, taskId, "Step two");
// Chat still focused, indicator re-armed
await expect(planTabLocator(testPage)).not.toHaveClass(/dv-active-tab/);
await expect(planTabIndicator(testPage)).toBeVisible({ timeout: 15_000 });
await expectPlanIndicatorVisible(testPage, session);
// Clicking the Plan tab shows the updated content
await session.clickTab("Plan");
@@ -165,13 +199,14 @@ test.describe("Plan panel auto-open + indicator", () => {
"plan indicator refresh",
CREATE_PLAN_SCRIPT,
);
await waitForAgentPlan(apiClient, taskId, "Step one");
await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 });
// The plan-update WS event arrives separately from the agent's idle
// signal — the tab mounts as soon as the panel registers, but the
// indicator only flips on once `plan.created_by === "agent"` is in the
// store. Match the tab's 15s budget instead of using the default 5s,
// which raced the WS push under shard load.
await expect(planTabIndicator(testPage)).toBeVisible({ timeout: 15_000 });
await expectPlanIndicatorVisible(testPage, session);
// Acknowledge
await session.clickTab("Plan");
+73 -6
View File
@@ -2,6 +2,7 @@ import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
import type { Page } from "@playwright/test";
async function seedBadgeTest(
apiClient: ApiClient,
@@ -42,6 +43,56 @@ async function seedBadgeTest(
return { workflow, inboxStep, workingStep, doneStep, task };
}
type TaskPR = NonNullable<Awaited<ReturnType<ApiClient["getTaskPR"]>>>;
async function waitForTaskPRFields(
apiClient: ApiClient,
taskId: string,
expected: Partial<Pick<TaskPR, "state" | "review_state" | "checks_state" | "mergeable_state">> & {
pending_review_count?: number;
},
) {
await expect
.poll(
async () => {
const pr = await apiClient.getTaskPR(taskId);
if (!pr) return false;
return Object.entries(expected).every(([key, value]) => pr[key as keyof TaskPR] === value);
},
{
timeout: 15_000,
message: "Expected backend TaskPR fields to match seeded mock state",
},
)
.toBe(true);
}
async function expectTopbarReadyState(
page: Page,
session: SessionPage,
expected: "true" | "false",
) {
const button = session.prTopbarButton();
await button.waitFor({ state: "visible", timeout: 15_000 });
await button
.waitFor({ state: "attached", timeout: 1_000 })
.then(() =>
expect(button).toHaveAttribute("data-pr-ready-to-merge", expected, { timeout: 5_000 }),
)
.catch(async () => {
// The topbar PR button hydrates from task-pr state that can arrive via a
// github.task_pr.updated WS event. If the event was missed during task
// navigation, a reload rehydrates from the backend state asserted above.
await page.reload();
await session.waitForLoad();
});
await expect(session.prTopbarButton()).toHaveAttribute("data-pr-ready-to-merge", expected, {
timeout: 15_000,
});
}
test.describe("PR status badge", () => {
/**
* Regression for the "CI pending" bug: GitHub reports all checks passed
@@ -87,6 +138,7 @@ test.describe("PR status badge", () => {
state: "open",
checks_state: "success",
});
await waitForTaskPRFields(apiClient, task.id, { state: "open", checks_state: "success" });
await expect(kanban.taskCardInColumn("CI Skipped Task", doneStep.id)).toBeVisible({
timeout: 45_000,
@@ -106,8 +158,7 @@ test.describe("PR status badge", () => {
await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 });
await expect(session.prTopbarButton()).toHaveAttribute("data-pr-ready-to-merge", "false");
await expectTopbarReadyState(testPage, session, "false");
});
/**
@@ -149,6 +200,12 @@ test.describe("PR status badge", () => {
checks_state: "success",
mergeable_state: "clean",
});
await waitForTaskPRFields(apiClient, task.id, {
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "clean",
});
await expect(kanban.taskCardInColumn("Ready To Merge Task", doneStep.id)).toBeVisible({
timeout: 45_000,
@@ -164,10 +221,7 @@ test.describe("PR status badge", () => {
await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 });
await expect(session.prTopbarButton()).toHaveAttribute("data-pr-ready-to-merge", "true", {
timeout: 15_000,
});
await expectTopbarReadyState(testPage, session, "true");
});
/**
@@ -209,6 +263,12 @@ test.describe("PR status badge", () => {
checks_state: "success",
mergeable_state: "blocked",
});
await waitForTaskPRFields(apiClient, task.id, {
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "blocked",
});
await expect(kanban.taskCardInColumn("Blocked Task", doneStep.id)).toBeVisible({
timeout: 45_000,
@@ -265,6 +325,13 @@ test.describe("PR status badge", () => {
mergeable_state: "blocked",
pending_review_count: 1,
});
await waitForTaskPRFields(apiClient, task.id, {
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "blocked",
pending_review_count: 1,
});
await expect(kanban.taskCardInColumn("Awaiting Review Task", doneStep.id)).toBeVisible({
timeout: 45_000,
@@ -1,10 +1,69 @@
import { execSync } from "node:child_process";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { makeGitEnv } from "../../helpers/git-helper";
import type { ApiClient } from "../../helpers/api-client";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
async function waitForReviewTasksInBackend(
apiClient: ApiClient,
workspaceId: string,
reviewStepId: string,
titles: string[],
) {
await expect
.poll(
async () => {
const { tasks } = await apiClient.listTasks(workspaceId);
return titles.filter((title) =>
tasks.some((task) => task.title === title && task.workflow_step_id === reviewStepId),
).length;
},
{
timeout: 60_000,
intervals: [500, 1_000, 2_000],
message: `Expected ${titles.length} PR tasks to exist in the Review step`,
},
)
.toBe(titles.length);
}
async function waitForReviewTaskCards(
testPage: Page,
kanban: KanbanPage,
reviewStepId: string,
titles: string[],
) {
async function visibleCardCount() {
const visible = await Promise.all(
titles.map((title) => kanban.taskCardInColumn(title, reviewStepId).isVisible()),
);
return visible.filter(Boolean).length;
}
const expected = titles.length;
await expect
.poll(visibleCardCount, { timeout: 10_000, intervals: [500, 1_000] })
.toBe(expected)
.catch(async () => {
// The watcher creates tasks from a background poll. If task.created events
// arrive before the kanban subscription is fully attached, backend state is
// correct but one card can be absent until SSR rehydrates the board.
await testPage.reload();
await kanban.board.waitFor({ state: "visible", timeout: 15_000 });
});
await expect
.poll(visibleCardCount, {
timeout: 90_000,
intervals: [500, 1_000, 2_000],
message: `Expected all ${expected} PR task cards (${titles.join(", ")}) to appear in the Review column`,
})
.toBe(expected);
}
test.describe("PR watcher dockview layout stability", () => {
/**
* Verifies the dockview layout stays correct when switching between
@@ -149,21 +208,8 @@ test.describe("PR watcher dockview layout stability", () => {
// out of order under load; waiting for the whole set at once avoids
// cumulative per-card timeout pressure and settles as soon as all 3 appear.
const prTaskTitles = [prTask1Title, prTask2Title, prTask3Title];
await expect
.poll(
async () => {
const visible = await Promise.all(
prTaskTitles.map((title) => kanban.taskCardInColumn(title, reviewStep.id).isVisible()),
);
return visible.filter(Boolean).length;
},
{
timeout: 90_000,
intervals: [500, 1_000, 2_000],
message: `Expected all 3 PR task cards (${prTaskTitles.join(", ")}) to appear in the Review column`,
},
)
.toBe(3);
await waitForReviewTasksInBackend(apiClient, seedData.workspaceId, reviewStep.id, prTaskTitles);
await waitForReviewTaskCards(testPage, kanban, reviewStep.id, prTaskTitles);
// --- Click PR task 1 to enter session view ---
await kanban.taskCardInColumn(prTask1Title, reviewStep.id).click();
@@ -24,14 +24,19 @@ export async function switchToTerminalPanel(testPage: Page): Promise<void> {
export async function readTerminalBuffer(page: Page): Promise<string> {
return page.evaluate(() => {
const panel = document.querySelector('[data-testid="terminal-panel"]');
const xtermEl = panel?.querySelector(".xterm");
const panels = Array.from(document.querySelectorAll('[data-testid="terminal-panel"]'));
const visiblePanels = panels.filter((panel) => panel.getClientRects().length > 0);
const panel = visiblePanels.at(-1) ?? panels.at(-1);
type XC = HTMLElement & { __xtermReadBuffer?: () => string };
const container = xtermEl?.parentElement as XC | null | undefined;
const container = panel?.querySelector('[data-testid="terminal-xterm-host"]') as XC | null;
return container?.__xtermReadBuffer?.() ?? "";
});
}
export async function focusTerminalForTyping(testPage: Page): Promise<void> {
await testPage.getByTestId("terminal-panel").last().getByTestId("terminal-xterm-host").click();
}
/**
* Wait for the mobile shell to be ready by tailing xterm's buffer until it has
* any content (a prompt is enough). Mobile mounts the terminal lazily on tab
@@ -12,7 +12,11 @@ import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
import { MobileTerminalKeybarPage } from "../../pages/mobile-terminal-keybar-page";
import { attachShellInputCapture, type ShellInputFrame } from "../../helpers/ws-capture";
import { switchToTerminalPanel, waitForShellReady } from "./mobile-terminal-helpers";
import {
focusTerminalForTyping,
switchToTerminalPanel,
waitForShellReady,
} from "./mobile-terminal-helpers";
async function seedTaskWithSession(
testPage: Page,
@@ -38,14 +42,6 @@ async function seedTaskWithSession(
return session;
}
/**
* Bring up the OS keyboard the way a user would: tap inside the terminal so
* xterm focuses its hidden textarea.
*/
async function focusTerminalForTyping(session: SessionPage): Promise<void> {
await session.terminal.locator(".xterm").last().click();
}
/**
* Drives the page's `visualViewport` the way the OS would when the keyboard
* opens or the user scrolls with it open. Pure event-firing — nothing about
@@ -109,7 +105,7 @@ test.describe("Mobile terminal key-bar — user flows", () => {
await expect(keybar.root).toBeVisible({ timeout: 10_000 });
await waitForShellReady(testPage);
await focusTerminalForTyping(session);
await focusTerminalForTyping(testPage);
// User types a command on the OS keyboard, then runs it.
await testPage.keyboard.type("sleep 30");
@@ -130,13 +126,13 @@ test.describe("Mobile terminal key-bar — user flows", () => {
}) => {
test.setTimeout(90_000);
const { frames } = attachShellInputCapture(testPage);
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Keybar Ctrl+L");
await seedTaskWithSession(testPage, apiClient, seedData, "Keybar Ctrl+L");
await switchToTerminalPanel(testPage);
const keybar = new MobileTerminalKeybarPage(testPage);
await expect(keybar.root).toBeVisible({ timeout: 10_000 });
await waitForShellReady(testPage);
await focusTerminalForTyping(session);
await focusTerminalForTyping(testPage);
// User taps Ctrl on the bar — visible state change.
await keybar.ctrl.tap();
@@ -158,13 +154,13 @@ test.describe("Mobile terminal key-bar — user flows", () => {
}) => {
test.setTimeout(90_000);
const { frames } = attachShellInputCapture(testPage);
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Keybar sticky Ctrl");
await seedTaskWithSession(testPage, apiClient, seedData, "Keybar sticky Ctrl");
await switchToTerminalPanel(testPage);
const keybar = new MobileTerminalKeybarPage(testPage);
await expect(keybar.root).toBeVisible({ timeout: 10_000 });
await waitForShellReady(testPage);
await focusTerminalForTyping(session);
await focusTerminalForTyping(testPage);
// Two taps → sticky.
await keybar.ctrl.tap();
@@ -215,13 +211,13 @@ test.describe("Mobile terminal key-bar — user flows", () => {
}) => {
test.setTimeout(90_000);
const { frames } = attachShellInputCapture(testPage);
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Keybar passthrough");
await seedTaskWithSession(testPage, apiClient, seedData, "Keybar passthrough");
await switchToTerminalPanel(testPage);
const keybar = new MobileTerminalKeybarPage(testPage);
await expect(keybar.root).toBeVisible({ timeout: 10_000 });
await waitForShellReady(testPage);
await focusTerminalForTyping(session);
await focusTerminalForTyping(testPage);
await testPage.keyboard.press("c");
@@ -10,6 +10,7 @@ import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
import {
focusTerminalForTyping,
readTerminalBuffer,
switchToTerminalPanel,
waitForShellReady,
@@ -41,18 +42,15 @@ async function seedTaskWithSession(
async function readViewportY(page: Page): Promise<number> {
return page.evaluate(() => {
const panel = document.querySelector('[data-testid="terminal-panel"]');
const xtermEl = panel?.querySelector(".xterm");
const panels = Array.from(document.querySelectorAll('[data-testid="terminal-panel"]'));
const visiblePanels = panels.filter((panel) => panel.getClientRects().length > 0);
const panel = visiblePanels.at(-1) ?? panels.at(-1);
type XC = HTMLElement & { __xtermReadViewportY?: () => number };
const container = xtermEl?.parentElement as XC | null | undefined;
const container = panel?.querySelector('[data-testid="terminal-xterm-host"]') as XC | null;
return container?.__xtermReadViewportY?.() ?? -1;
});
}
async function focusTerminalForTyping(session: SessionPage): Promise<void> {
await session.terminal.locator(".xterm").click();
}
/**
* Dispatch a synthetic single-finger swipe on the xterm container. We bypass
* Playwright's touchscreen because the gesture coordinates need to be relative
@@ -68,7 +66,10 @@ async function swipe(
await page.evaluate(
({ direction, steps, rowsToScroll }) => {
const panel = document.querySelector('[data-testid="terminal-panel"]');
const xtermEl = panel?.querySelector(".xterm") as HTMLElement | null;
// xterm registers touch handling on its generated element, so this test
// intentionally locates that implementation node for event dispatch.
const xterms = Array.from(panel?.querySelectorAll(".xterm") ?? []);
const xtermEl = (panel?.querySelector(".xterm.focus") ?? xterms.at(-1)) as HTMLElement | null;
if (!xtermEl) throw new Error("xterm element not found");
const rect = xtermEl.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
@@ -111,10 +112,10 @@ test.describe("Mobile passthrough terminal — touch scroll", () => {
seedData,
}) => {
test.setTimeout(120_000);
const session = await seedTaskWithSession(testPage, apiClient, seedData, "Touch scroll");
await seedTaskWithSession(testPage, apiClient, seedData, "Touch scroll");
await switchToTerminalPanel(testPage);
await waitForShellReady(testPage);
await focusTerminalForTyping(session);
await focusTerminalForTyping(testPage);
// Produce enough output to populate the scrollback past one viewport.
await typeAndRun(testPage, "for i in $(seq 1 200); do echo line $i; done");