From fe594884c6b72a9222a05b22d11bc692b1e2cbf0 Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 20 Oct 2026 19:47:45 -0400 Subject: [PATCH] test(app): update e2e tests for the session details menu (#57560) --- packages/app/e2e/regression/home.spec.ts | 2 +- .../regression/mcp-workspace-toggle.spec.ts | 70 ++++++++++++---- .../mobile-extension-drawers.spec.ts | 18 ++-- .../app/e2e/regression/mobile-session.spec.ts | 2 +- .../e2e/regression/session-summary.spec.ts | 82 +++++++------------ 5 files changed, 91 insertions(+), 83 deletions(-) diff --git a/packages/app/e2e/regression/home.spec.ts b/packages/app/e2e/regression/home.spec.ts index f637ad85007..f8073aa4b29 100644 --- a/packages/app/e2e/regression/home.spec.ts +++ b/packages/app/e2e/regression/home.spec.ts @@ -405,7 +405,7 @@ for (const state of [ for (const loaded of state.recovery ? [false, true] : [true]) { if (state.recovery && loaded) { messages.resolve() - await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible() + await expect(header.getByRole("button", { name: "Session details", exact: true })).toBeVisible() } await expect(trigger.locator('[data-slot="session-location-project"]')).toHaveText(name) diff --git a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts index a5ff7edf41f..141a3a08754 100644 --- a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts +++ b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts @@ -16,7 +16,8 @@ type Surface = "popover" | "dialog" // A session in a worktree of the project. The harness answers the MCP list for each location directory and stores // connect/disconnect results per directory; `requests` records every MCP request and the directory it targeted. -// The popover belongs to the summary extension; the dialog (Ctrl+;) is the app's own MCP toggle. +// The popover is the session details menu's MCP submenu, which shows state but does not toggle; the dialog (Ctrl+;) is +// the app's own MCP toggle. async function open( page: Page, input: { surface: Surface } & Pick, @@ -45,12 +46,22 @@ async function open( await page.goto(sessionHref(sessionID)) await expectSessionTitle(page, title) await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable() - const panel = page.getByRole("dialog", { name: input.surface === "popover" ? "MCP" : "MCPs", exact: true }) + + const panel = + input.surface === "popover" + ? page.getByRole("menu", { name: "MCP", exact: true }) + : page.getByRole("dialog", { name: "MCPs", exact: true }) const show = async () => { if (input.surface === "dialog") return page.keyboard.press("Control+;") await page.getByRole("button", { name: "Session details", exact: true }).click() - await page.getByRole("button", { name: "MCP", exact: true }).click() + const item = page.getByRole("menuitem", { name: "MCP", exact: true }) + await item.click() + await expect(panel).toBeVisible() + // Straight across into the submenu, so the pointer never crosses the items below MCP. + const from = (await item.boundingBox())! + const to = (await panel.boundingBox())! + await page.mouse.move(to.x + to.width / 2, from.y + from.height / 2, { steps: 5 }) } await show() @@ -66,7 +77,7 @@ for (const shared of [true, false]) { const actions: Parameters>[0][] = [] const view = await open(page, { - surface: "popover", + surface: "dialog", mcp: (target) => !shared && target !== workspace ? [] : [{ name: "figma-desktop", status: { status: "disabled" } }], onMcpAction: (action) => void actions.push(action), @@ -85,7 +96,7 @@ for (const shared of [true, false]) { expect(view.requests.every((request) => request.directory === workspace)).toBe(true) view.requests.length = 0 - await view.panel.locator('[data-slot="truncated-text-label"]').filter({ hasText: "figma-desktop" }).click() + await view.panel.locator('[data-slot="switch-control"]').click() await expect(view.toggle).not.toBeChecked() await expect(view.toggle).toBeEnabled() expect(actions).toEqual([ @@ -100,7 +111,37 @@ for (const shared of [true, false]) { }) } -for (const surface of ["popover", "dialog"] as const) { +test("the MCP popover shows a failed server and reconnects it from its details", async ({ page }) => { + const error = "Streamable HTTP error: Error POSTing to endpoint: 404 Not Found" + + const view = await open(page, { + surface: "popover", + mcp: (target) => [ + { name: "figma-desktop", status: target !== workspace ? { status: "connected" } : { status: "failed", error } }, + ], + }) + + const row = view.panel.locator(".session-mcp-row").filter({ hasText: "figma-desktop" }) + await expect(view.toggle).toHaveCount(0) + await expect(row.getByText("Failed", { exact: true })).toBeVisible() + const failed = view.panel.getByRole("button", { name: "Details for figma-desktop", exact: true }) + await expect(failed).toHaveText("Details") + view.requests.length = 0 + await failed.click() + const details = page.getByRole("dialog", { name: "figma-desktop", exact: true }) + await expect(details).toHaveAccessibleDescription("Connection failed") + await expect(details.getByText(error, { exact: true })).toBeVisible() + await details.getByRole("button", { name: "Try again", exact: true }).click() + await expect(details).toBeHidden() + expect(view.requests.filter((request) => request.path.endsWith("/connect"))).toEqual([ + { path: "/api/experimental/mcp/figma-desktop/connect", directory: workspace }, + ]) + + if (!(await view.panel.isVisible())) await view.show() + await expect(row.getByText("Connected", { exact: true })).toBeVisible() +}) + +for (const surface of ["dialog"] as const) { test(`shows connection failures from the MCP ${surface} and allows reconnecting`, async ({ page }) => { const error = "Streamable HTTP error: Error POSTing to endpoint: 404 Not Found" const state = { fail: true } @@ -175,13 +216,6 @@ for (const surface of ["popover", "dialog"] as const) { state.fail = false await details.getByRole("button", { name: "Try again", exact: true }).click() - - if (surface === "popover") { - await expect(details).toBeHidden() - - if (!(await view.panel.isVisible())) await view.show() - } - await expect(view.toggle).toBeChecked() await expect(view.toggle).toBeEnabled() }) @@ -243,7 +277,15 @@ for (const surface of ["popover", "dialog"] as const) { ]) if (!(await view.panel.isVisible())) await view.show() - await expect(view.toggle).toBeChecked() + + if (surface === "dialog") await expect(view.toggle).toBeChecked() + else + await expect( + view.panel + .locator(".session-mcp-row") + .filter({ hasText: "figma-desktop" }) + .getByText("Connected", { exact: true }), + ).toBeVisible() await expect(view.panel.getByRole("button", { name: "Sign in to figma-desktop", exact: true })).toHaveCount(0) }) } diff --git a/packages/app/e2e/regression/mobile-extension-drawers.spec.ts b/packages/app/e2e/regression/mobile-extension-drawers.spec.ts index 4d604387fbe..854db77bec7 100644 --- a/packages/app/e2e/regression/mobile-extension-drawers.spec.ts +++ b/packages/app/e2e/regression/mobile-extension-drawers.spec.ts @@ -70,7 +70,7 @@ for (const scheme of ["light", "dark"] as const) { } } -test("mobile extension drawers keep configured lists, retry, refresh, and MCP toggles working", async ({ page }) => { +test("mobile extension drawers keep configured lists, retry, refresh, and MCP status working", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await mockStressTimeline(page) const state = { fail: true, extra: false, enabled: true } @@ -182,7 +182,8 @@ test("mobile extension drawers keep configured lists, retry, refresh, and MCP to await details.getByRole("button", { name: "MCP", exact: true }).click() const mcp = page.getByRole("dialog", { name: "MCP", exact: true }) - await expect(mcp.getByRole("switch")).toHaveCount(30) + await expect(mcp.locator(".session-mcp-row")).toHaveCount(30) + await expect(mcp.getByRole("switch")).toHaveCount(0) await expect .poll(() => mcp @@ -191,18 +192,9 @@ test("mobile extension drawers keep configured lists, retry, refresh, and MCP to ) .toBe(true) await expect(mcp).toBeInViewport({ ratio: 1 }) - const disconnect = page.waitForRequest((request) => new URL(request.url()).pathname.endsWith("/disconnect")) - await mcp.getByRole("switch", { name: "mcp-00", exact: true }).focus() - await mcp.getByRole("switch", { name: "mcp-00", exact: true }).press("Space") - await disconnect - await expect(mcp.getByRole("switch", { name: "mcp-00", exact: true })).not.toBeChecked() - - const last = mcp - .locator(".session-mcp-row") - .filter({ has: page.getByRole("switch", { name: "mcp-29", exact: true }) }) + const last = mcp.locator(".session-mcp-row").filter({ hasText: "mcp-29" }) await last.scrollIntoViewIfNeeded() await expect(last).toBeInViewport() - await mcp.getByRole("switch", { name: "mcp-29", exact: true }).focus() - await expect(mcp.getByRole("switch", { name: "mcp-29", exact: true })).toBeFocused() + await expect(mcp.getByRole("button", { name: "Manage MCP servers", exact: true })).toBeVisible() }) diff --git a/packages/app/e2e/regression/mobile-session.spec.ts b/packages/app/e2e/regression/mobile-session.spec.ts index d7ac4dcea78..48dbe12c152 100644 --- a/packages/app/e2e/regression/mobile-session.spec.ts +++ b/packages/app/e2e/regression/mobile-session.spec.ts @@ -124,7 +124,7 @@ for (const position of ["top", "bottom"] as const) { await expect(details.locator('[data-slot="mobile-panel-header"]')).toHaveCount(0) await expect(details.getByRole("button", { name: "Close", exact: true })).toHaveCount(0) await expect(details.getByRole("heading", { name: "Session details", exact: true })).toHaveCSS("width", "1px") - await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible() + await expect(details.getByRole("button", { name: "Local repository", exact: true })).toBeVisible() await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible() await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible() await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } }) diff --git a/packages/app/e2e/regression/session-summary.spec.ts b/packages/app/e2e/regression/session-summary.spec.ts index 45cb9518092..80ae945f3fe 100644 --- a/packages/app/e2e/regression/session-summary.spec.ts +++ b/packages/app/e2e/regression/session-summary.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Locator, type Page } from "@playwright/test" import { SERVER, holdRoute, seed, sessionHref } from "../utils/app" import { openWithDirection } from "../utils/direction" import { mockOpenCodeServer } from "../utils/mock-server" @@ -89,10 +89,12 @@ test("a session in a worktree subfolder names its worktree and lists cached work const list = await holdRoute(page, (url) => url.pathname === "/api/worktree", { method: "GET" }) await trigger.click() await location.click() + await enter(page, location, page.getByRole("menu").filter({ hasText: "Move session to" })) await list.arrived await page.getByRole("menuitem", { name: "Worktree", exact: true }).click() - await expect(page.getByRole("menuitem", { name: "other", exact: true })).toBeVisible() - await expect(page.getByRole("menuitem", { name: "feature", exact: true })).toHaveCount(0) + const existing = page.getByRole("menu").filter({ has: page.getByRole("menuitem", { name: "other", exact: true }) }) + await expect(existing.getByRole("menuitem", { name: "other", exact: true })).toBeVisible() + await expect(existing.getByRole("menuitem", { name: "feature", exact: true })).toHaveCount(0) list.release() }) @@ -312,7 +314,12 @@ test("summary catalogs load, refresh while cached, and tell errors from empty", await page.getByRole("button", { name: "Session details", exact: true }).click() const summary = page.getByRole("menu", { name: "Session details", exact: true }) const menu = (name: string) => page.getByRole("menu", { name, exact: true }) - const open = (name: string) => summary.getByRole("menuitem", { name, exact: true }).click() + + const open = async (name: string) => { + const item = summary.getByRole("menuitem", { name, exact: true }) + await item.click() + await enter(page, item, menu(name)) + } await prefetch.arrived await expect(summary.getByRole("menuitem", { name: "Local repository", exact: true })).toBeVisible() @@ -393,8 +400,10 @@ test("MCP rows show each server's state and the menu opens the MCP servers dialo }) await page.goto(sessionHref(fixture.targetID)) await page.getByRole("button", { name: "Session details", exact: true }).click() - await page.getByRole("menuitem", { name: "MCP", exact: true }).click() + const item = page.getByRole("menuitem", { name: "MCP", exact: true }) + await item.click() const submenu = page.getByRole("menu", { name: "MCP", exact: true }) + await enter(page, item, submenu) const row = (name: string) => submenu.locator(".session-mcp-row").filter({ has: page.getByText(name, { exact: true }) }) @@ -417,53 +426,6 @@ test("MCP rows show each server's state and the menu opens the MCP servers dialo await expect(submenu).toBeHidden() }) -test("MCP authentication starts before a slow resource catalog finishes", async ({ page, context }) => { - const state = { connected: false } - const attempts: string[] = [] - await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" })) - await mockStressTimeline(page, { - mcp: [{ name: "linear", integrationID: "linear-oauth", status: { status: "needs_auth" } }], - onMcpAction: () => { - state.connected = true - - return { status: "needs_auth" } - }, - integrations: [{ id: "linear-oauth", name: "Linear", methods: [{ id: "oauth", type: "oauth" }], connections: [] }], - onIntegrationOAuth: () => ({ url: "https://auth.example.test/authorize" }), - }) - page.on("request", (request) => { - if (request.method() === "POST" && new URL(request.url()).pathname.startsWith("/api/integration/")) - attempts.push(request.url()) - }) - - // Only the resource catalog refresh after connecting is slow. - const resources = await holdRoute(page, (url) => state.connected && url.pathname === "/api/mcp/resource", { - method: "GET", - }) - - await page.goto(sessionHref(fixture.targetID)) - await page.getByRole("button", { name: "Session details", exact: true }).click() - await page.getByRole("menuitem", { name: "MCP", exact: true }).click() - const submenu = page.getByRole("menu", { name: "MCP", exact: true }) - const signIn = submenu.getByRole("button", { name: "Sign in to linear", exact: true }) - await expect(signIn).toBeEnabled() - - try { - const popup = page.waitForEvent("popup") - await signIn.click() - await expect(await popup).toHaveURL("https://auth.example.test/authorize") - await resources.arrived - // The sign-in link waits for the catalog before it is ready again. - await expect(signIn).toBeDisabled() - } finally { - resources.release() - } - - await expect(signIn).toBeEnabled() - expect(attempts).toHaveLength(1) - expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory) -}) - test("multiple desktop connections show the session's server name", async ({ page }) => { await mockStressTimeline(page) await page.route("http://secondary.test/**", (route) => @@ -523,8 +485,10 @@ test.describe("remote configuration", () => { { name: "LSP", path: `${home}/lsp.json` }, ].entries()) { await trigger.click() - await summary.getByRole("menuitem", { name: service.name, exact: true }).click() + const item = summary.getByRole("menuitem", { name: service.name, exact: true }) + await item.click() const menu = page.getByRole("menu", { name: service.name, exact: true }) + await enter(page, item, menu) const copy = menu.getByRole("button", { name: "Copy configuration file path", exact: true }) await expect(tooltip).toHaveCount(0) await copy.hover() @@ -552,7 +516,9 @@ test.describe("remote configuration", () => { entries.length = 0 await page.evaluate(() => navigator.clipboard.writeText("original clipboard")) await trigger.click() - await summary.getByRole("menuitem", { name: "Skills", exact: true }).click() + const skills = summary.getByRole("menuitem", { name: "Skills", exact: true }) + await skills.click() + await enter(page, skills, page.getByRole("menu", { name: "Skills", exact: true })) const copy = page .getByRole("menu", { name: "Skills", exact: true }) @@ -592,6 +558,14 @@ test("long MCP lists stay in the viewport in a real RTL locale", async ({ page } await expect(menu).toBeHidden() }) +// Moves the pointer straight across from a submenu's trigger into the open submenu, so it never crosses sibling items. +async function enter(page: Page, trigger: Locator, menu: Locator) { + await expect(menu).toBeVisible() + const from = (await trigger.boundingBox())! + const to = (await menu.boundingBox())! + await page.mouse.move(to.x + to.width / 2, from.y + from.height / 2, { steps: 5 }) +} + // Solid reports reactive work created without an owner; the warning appears in dev builds only. function ownerWarnings(page: Page) { const warnings: string[] = []