mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
791 lines
33 KiB
TypeScript
791 lines
33 KiB
TypeScript
import type { OpenCodeEvent } from "@opencode/client/promise"
|
|
import { expect, test } from "@playwright/test"
|
|
import {
|
|
captureConsoleWarnings,
|
|
openCommandPalette,
|
|
openPaletteWorkspace,
|
|
paletteSession,
|
|
} from "../utils/command-palette"
|
|
import { REMOTE_SERVER, SERVER } from "../utils/app"
|
|
import { mockRemoteServer, mockWorkspace } from "../utils/workspace"
|
|
|
|
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
|
|
|
|
test("copies the session ID while file and session searches are still pending", async ({ page }) => {
|
|
const warnings = captureConsoleWarnings(page)
|
|
const { dialog, input } = await openCommandPalette(page)
|
|
const release = Promise.withResolvers<void>()
|
|
await page.route(/\/api\/(session\?|fs\/find\?)/, async (route) => {
|
|
await release.promise
|
|
await route.fallback()
|
|
})
|
|
await input.pressSequentially("copy session")
|
|
const copy = dialog.getByRole("option", { name: "Copy Session ID", exact: true })
|
|
await expect(copy).toHaveAttribute("aria-selected", "true")
|
|
await input.press("Enter")
|
|
await expect(dialog).toHaveCount(0)
|
|
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(paletteSession.id)
|
|
await expect(page.locator('[data-testid^="toast-v2-"] [data-slot="icon-svg"]')).toBeVisible()
|
|
expect(warnings).toEqual([])
|
|
release.resolve()
|
|
})
|
|
|
|
test("home commands do not wait for session search", async ({ page }) => {
|
|
const { dialog, input } = await openCommandPalette(page, true)
|
|
const release = Promise.withResolvers<void>()
|
|
await page.route("**/api/session?*", async (route) => {
|
|
await release.promise
|
|
await route.fallback()
|
|
})
|
|
await input.fill("open settings")
|
|
await expect(dialog.getByRole("option")).toHaveCount(1)
|
|
await expect(dialog.getByRole("option", { name: /^Open settings/ })).toHaveAttribute("aria-selected", "true")
|
|
await input.press("Enter")
|
|
await expect(page).toHaveURL("/settings")
|
|
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
|
|
release.resolve()
|
|
})
|
|
|
|
test("finds a setting and opens Settings with its row in view and highlighted", async ({ page }) => {
|
|
await openPaletteWorkspace(page)
|
|
await page.keyboard.press("Control+K")
|
|
const dialog = page.getByRole("dialog")
|
|
const input = dialog.getByRole("combobox")
|
|
await expect(input).toBeFocused()
|
|
await input.fill("theme")
|
|
await expect(dialog.getByRole("option", { name: /^Theme/ })).toHaveAttribute("aria-selected", "true")
|
|
|
|
// Settings list after commands, and the keyboard walks rows in their visual order.
|
|
const searched = Promise.all([
|
|
page.waitForResponse((response) => new URL(response.url()).pathname === "/api/session"),
|
|
page.waitForResponse((response) => new URL(response.url()).pathname === "/api/fs/find"),
|
|
])
|
|
|
|
await input.fill("model")
|
|
await searched
|
|
const titles = dialog.locator('[data-slot="search-list-header"]')
|
|
await expect(titles.first()).toHaveText("Commands")
|
|
await expect(titles.last()).toHaveText("Settings")
|
|
const options = dialog.getByRole("option")
|
|
|
|
for (const index of Array.from({ length: await options.count() }, (_, index) => index)) {
|
|
await expect(options.nth(index)).toHaveAttribute("aria-selected", "true")
|
|
await input.press("ArrowDown")
|
|
}
|
|
|
|
await input.fill("theme")
|
|
await expect(dialog.getByRole("option", { name: /^Theme/ })).toHaveAttribute("aria-selected", "true")
|
|
await input.press("Enter")
|
|
await expect(page).toHaveURL("/settings?tab=appearance")
|
|
const row = page.getByTestId("settings-screen").locator('[data-search-target="row"]')
|
|
await expect(row).toContainText("Theme")
|
|
await expect(row).toBeInViewport()
|
|
})
|
|
|
|
test("opens from the Settings page and finds another setting", async ({ page }) => {
|
|
await openPaletteWorkspace(page, true)
|
|
await page.keyboard.press("ControlOrMeta+,")
|
|
await expect(page).toHaveURL("/settings")
|
|
const screen = page.getByTestId("settings-screen")
|
|
await expect(screen.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
|
|
|
|
await page.keyboard.press("ControlOrMeta+K")
|
|
const dialog = page.getByRole("dialog")
|
|
const input = dialog.getByRole("combobox")
|
|
await expect(input).toBeFocused()
|
|
await input.fill("theme")
|
|
await expect(dialog.getByRole("option", { name: /^Theme/ })).toHaveAttribute("aria-selected", "true")
|
|
await input.press("Enter")
|
|
await expect(dialog).toHaveCount(0)
|
|
await expect(page).toHaveURL("/settings?tab=appearance")
|
|
const row = screen.locator('[data-search-target="row"]')
|
|
await expect(row).toContainText("Theme")
|
|
await expect(row).toBeInViewport()
|
|
})
|
|
|
|
test("lists recent sessions before a query is entered", async ({ page }) => {
|
|
const { dialog } = await openCommandPalette(page, true)
|
|
const session = dialog.getByRole("option", { name: /Palette fixture session/ })
|
|
await expect(dialog.getByText("Recent sessions", { exact: true })).toBeVisible()
|
|
await expect(session).toHaveAttribute("aria-selected", "false")
|
|
await session.click()
|
|
await expect(dialog).toHaveCount(0)
|
|
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
|
})
|
|
|
|
test("titlebar search lists recent sessions and opens the typed match", async ({ page }) => {
|
|
await openPaletteWorkspace(page, true)
|
|
await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click()
|
|
const dialog = page.getByRole("dialog")
|
|
const input = dialog.getByRole("combobox")
|
|
await expect(input).toBeFocused()
|
|
await expect(input).toHaveAttribute("placeholder", "Search sessions")
|
|
await expect(dialog.getByText("Recent sessions", { exact: true })).toBeVisible()
|
|
await expect(dialog.getByRole("option", { name: /Palette fixture session/ })).toHaveAttribute("aria-selected", "true")
|
|
// Session search lists sessions only, never commands.
|
|
await input.fill("settings")
|
|
await expect(dialog.getByText("No results found", { exact: true })).toBeVisible()
|
|
await input.fill("Palette fixture")
|
|
await expect(dialog.getByRole("option")).toHaveCount(1)
|
|
await input.press("Enter")
|
|
await expect(dialog).toHaveCount(0)
|
|
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
|
})
|
|
|
|
test("titlebar search asks for one bounded result set after a pause in typing", async ({ page }) => {
|
|
await openPaletteWorkspace(page, true)
|
|
const searches: URL[] = []
|
|
page.on("request", (request) => {
|
|
const url = new URL(request.url())
|
|
|
|
if (request.method() !== "GET" || url.pathname !== "/api/session") return
|
|
|
|
if (!url.searchParams.get("search")) return
|
|
|
|
searches.push(url)
|
|
})
|
|
await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click()
|
|
const dialog = page.getByRole("dialog")
|
|
const input = dialog.getByRole("combobox")
|
|
await expect(dialog.getByRole("option", { name: /Palette fixture session/ })).toHaveAttribute("aria-selected", "true")
|
|
|
|
const searched = page.waitForRequest((request) => {
|
|
const url = new URL(request.url())
|
|
|
|
return (
|
|
request.method() === "GET" &&
|
|
url.pathname === "/api/session" &&
|
|
url.searchParams.get("search") === "Palette fixture"
|
|
)
|
|
})
|
|
|
|
// A burst of input in one task is one pause: every keystroke must supersede the last, and only the pause searches.
|
|
await input.evaluate((element) => {
|
|
// SAFETY: the dialog's combobox is SearchList's own text input, which is an HTMLInputElement.
|
|
const field = element as HTMLInputElement
|
|
const value = "Palette fixture"
|
|
|
|
for (let index = 1; index <= value.length; index++) {
|
|
field.value = value.slice(0, index)
|
|
field.dispatchEvent(new InputEvent("input", { bubbles: true }))
|
|
}
|
|
})
|
|
|
|
const request = new URL((await searched).url())
|
|
await expect(dialog.getByRole("option", { name: /Palette fixture session/ })).toBeVisible()
|
|
expect(searches).toHaveLength(1)
|
|
expect(request.searchParams.get("limit")).toBe("50")
|
|
expect(request.searchParams.get("parentID")).toBe("null")
|
|
expect(request.searchParams.get("archived")).toBe("false")
|
|
expect(request.searchParams.get("cursor")).toBeNull()
|
|
})
|
|
|
|
test("titlebar search shows one server's matches while another server's search fails", async ({ page }) => {
|
|
await mockRemoteServer(page, { name: "Remote" })
|
|
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
// Only typed searches fail, so the remote server still lists its recent sessions.
|
|
await page.route(new RegExp(`^${escape(REMOTE_SERVER)}/api/session\\?.*search=[^&]`), (route) =>
|
|
route.request().method() === "GET"
|
|
? route.fulfill({
|
|
status: 500,
|
|
json: { message: "Search failed" },
|
|
headers: { "access-control-allow-origin": "*" },
|
|
})
|
|
: route.fallback(),
|
|
)
|
|
await openPaletteWorkspace(page, true)
|
|
// The server matches more than titles, so only its answer can list this session. Registered after the local mock so
|
|
// this route answers first.
|
|
await page.route(new RegExp(`^${escape(SERVER)}/api/session\\?.*search=[^&]`), (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
data: [
|
|
{
|
|
...paletteSession,
|
|
id: "ses_palette_found",
|
|
location: { directory: paletteSession.directory },
|
|
title: "Matched on the server",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
)
|
|
await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click()
|
|
const dialog = page.getByRole("dialog")
|
|
await dialog.getByRole("combobox").fill("needle")
|
|
await expect(dialog.getByRole("option")).toHaveText([/Matched on the server/])
|
|
await expect(dialog.getByText("Request failed", { exact: true })).toHaveCount(0)
|
|
})
|
|
|
|
test("a working session keeps its project avatar in Search sessions and shows its activity ring in place of its age", async ({
|
|
page,
|
|
}) => {
|
|
const working = { id: "ses_palette_working", title: "Working session" }
|
|
const idle = { id: "ses_palette_idle", title: "Idle session" }
|
|
await mockWorkspace(page, {
|
|
name: "PaletteActivity",
|
|
sessions: [working, idle],
|
|
sessionStatus: { [working.id]: { type: "running" } },
|
|
})
|
|
await page.goto("/")
|
|
await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click()
|
|
const dialog = page.getByRole("dialog")
|
|
const workingRow = dialog.getByRole("option", { name: /Working session/ })
|
|
const idleRow = dialog.getByRole("option", { name: /Idle session/ })
|
|
await expect(workingRow.locator('[data-component="activity-ring"]')).toBeVisible()
|
|
await expect(workingRow.locator('[data-component="project-avatar-v2"]')).toBeVisible()
|
|
await expect(workingRow.locator(".command-palette-meta")).toHaveText("Working")
|
|
await expect(idleRow.locator('[data-component="activity-ring"]')).toHaveCount(0)
|
|
await expect(idleRow.locator('[data-component="project-avatar-v2"]')).toBeVisible()
|
|
await expect(idleRow.locator(".command-palette-meta")).not.toHaveText("Working")
|
|
})
|
|
|
|
test("Search commands lists only commands and runs the chosen one", async ({ page }) => {
|
|
const { dialog, input } = await openCommandPalette(page, true)
|
|
await input.fill("search commands")
|
|
await dialog.getByRole("option", { name: /^Search commands/ }).click()
|
|
const search = page.getByRole("dialog").getByRole("combobox")
|
|
await expect(search).toHaveAttribute("placeholder", "Search commands")
|
|
await expect(search).toBeFocused()
|
|
await expect(page.getByRole("dialog").getByRole("option", { name: /^Open settings/ })).toBeVisible()
|
|
await search.fill("Palette fixture")
|
|
await expect(page.getByRole("dialog").getByText("No results found", { exact: true })).toBeVisible()
|
|
await search.fill("open settings")
|
|
await expect(page.getByRole("dialog").getByRole("option")).toHaveCount(1)
|
|
await search.press("Enter")
|
|
await expect(page).toHaveURL("/settings")
|
|
})
|
|
|
|
test("Search projects opens the full location picker from a session, draft, and Home", async ({ page }) => {
|
|
const editor = page.locator('[data-component="composer-editor"]')
|
|
const picker = page.getByRole("combobox", { name: "Search projects", exact: true })
|
|
await openPaletteWorkspace(page)
|
|
const shortcut = (await page.evaluate(() => navigator.platform.includes("Mac"))) ? "Meta+Shift+O" : "Control+Shift+O"
|
|
await page.keyboard.press(shortcut)
|
|
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
|
await expect(picker).toBeFocused()
|
|
const projects = page.getByRole("dialog").getByRole("option")
|
|
await expect(projects.filter({ hasText: "Add project" })).toBeVisible()
|
|
await picker.fill("command-palette")
|
|
await expect(projects).toHaveCount(2)
|
|
await expect(projects.getByRole("button", { name: "Worktrees", exact: true })).toBeVisible()
|
|
await picker.press("ArrowRight")
|
|
await expect(page.getByRole("option", { name: /^New worktree/ })).toBeVisible()
|
|
await page.getByRole("combobox").press("Escape")
|
|
await picker.press("Escape")
|
|
await expect(picker).toHaveCount(0)
|
|
await expect(editor).toBeFocused()
|
|
|
|
await page.keyboard.press(shortcut)
|
|
await expect(picker).toBeFocused()
|
|
await expect(projects.filter({ hasText: "Add project" })).toBeVisible()
|
|
await picker.press("Escape")
|
|
|
|
await page.goto("/")
|
|
await expect(
|
|
page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /Palette fixture session/ }),
|
|
).toBeEnabled()
|
|
await page.keyboard.press(shortcut)
|
|
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
|
await expect(picker).toBeFocused()
|
|
await expect(projects.filter({ hasText: "Add project" })).toBeVisible()
|
|
})
|
|
|
|
declare global {
|
|
interface Window {
|
|
__paletteOrders?: string[][]
|
|
}
|
|
}
|
|
|
|
test("titlebar search keeps recent order stable across agent steps", async ({ page }) => {
|
|
const busy = { ...paletteSession, time: { ...paletteSession.time } }
|
|
|
|
const fresh = {
|
|
...paletteSession,
|
|
id: "ses_command_palette_fresh",
|
|
title: "Fresh fixture session",
|
|
time: { created: 1700000001000, updated: 1700000001000 },
|
|
}
|
|
|
|
const mock = await openPaletteWorkspace(page, true, [busy, fresh])
|
|
// A step bumps the busy session's time on the server, but only a finished run reorders recent sessions.
|
|
busy.time.updated = 1700000002000
|
|
// SAFETY: a step in a session whose transcript is not open; only its session ID and time matter here.
|
|
await mock.push([
|
|
{
|
|
id: "evt_busy_step",
|
|
created: busy.time.updated,
|
|
type: "session.step.started",
|
|
durable: { aggregateID: busy.id, seq: 1, version: 1 },
|
|
location: { directory: busy.directory },
|
|
data: {
|
|
sessionID: busy.id,
|
|
assistantMessageID: "msg_busy_step",
|
|
agent: "build",
|
|
model: { providerID: "test", id: "test" },
|
|
started: busy.time.updated,
|
|
},
|
|
} as OpenCodeEvent,
|
|
])
|
|
const home = page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /fixture session/ })
|
|
await expect(home).toHaveText([/Fresh fixture session/, /Palette fixture session/])
|
|
|
|
await page.evaluate(() => {
|
|
const seen: string[][] = []
|
|
window.__paletteOrders = seen
|
|
|
|
new MutationObserver(() => {
|
|
const order = [...document.querySelectorAll('[role="dialog"] [role="option"] .command-palette-title')].map(
|
|
(title) => title.textContent ?? "",
|
|
)
|
|
|
|
if (order.length > 0 && order.join() !== seen.at(-1)?.join()) seen.push(order)
|
|
}).observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
})
|
|
|
|
const dialog = page.getByRole("dialog")
|
|
|
|
const openAndClose = async () => {
|
|
const listed = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/session")
|
|
await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click()
|
|
await listed
|
|
await expect(dialog.getByRole("option")).toHaveText([/Fresh fixture session/, /Palette fixture session/])
|
|
await dialog.getByRole("combobox").press("Escape")
|
|
await expect(dialog).toHaveCount(0)
|
|
}
|
|
|
|
await openAndClose()
|
|
await openAndClose()
|
|
|
|
expect(await page.evaluate(() => window.__paletteOrders)).toEqual([[fresh.title, paletteSession.title]])
|
|
})
|
|
|
|
const olderSession = {
|
|
...paletteSession,
|
|
id: "ses_command_palette_older",
|
|
title: "Older fixture session",
|
|
time: { created: 1699999999000, updated: 1699999999000 },
|
|
}
|
|
|
|
test("titlebar search drops a recent session deleted while it is open", async ({ page }) => {
|
|
const mock = await openPaletteWorkspace(page, true, [paletteSession, olderSession])
|
|
await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click()
|
|
const options = page.getByRole("dialog").getByRole("option")
|
|
await expect(options).toHaveText([/Palette fixture session/, /Older fixture session/])
|
|
await mock.push([
|
|
{
|
|
id: "evt_older_deleted",
|
|
created: 1700000003000,
|
|
type: "session.deleted",
|
|
durable: { aggregateID: olderSession.id, seq: 1, version: 2 },
|
|
location: { directory: olderSession.directory },
|
|
data: { sessionID: olderSession.id },
|
|
},
|
|
])
|
|
await expect(options).toHaveText([/Palette fixture session/])
|
|
})
|
|
|
|
test("reopens from its shortcut as soon as it closes", async ({ page }) => {
|
|
const { dialog, input } = await openCommandPalette(page)
|
|
await input.press("Escape")
|
|
await expect(dialog).toHaveCount(0)
|
|
// A closed dialog stops blocking shortcuts when it leaves the screen, not when it is disposed afterwards.
|
|
await page.keyboard.press("ControlOrMeta+Shift+P")
|
|
await expect(dialog.getByRole("combobox")).toBeFocused()
|
|
})
|
|
|
|
test("appends search results without resetting the selected command", async ({ page }) => {
|
|
const { dialog, input } = await openCommandPalette(page)
|
|
const files = Promise.withResolvers<void>()
|
|
const sessions = Promise.withResolvers<void>()
|
|
await page.route("**/api/fs/find?*", async (route) => {
|
|
await files.promise
|
|
await route.fulfill({ json: { data: [{ path: "copy.txt", type: "file" }] } })
|
|
})
|
|
await page.route("**/api/session?*", async (route) => {
|
|
await sessions.promise
|
|
// Like the server, only a matching title answers, so the session leaves the results for "copy session".
|
|
const search = new URL(route.request().url()).searchParams.get("search") ?? ""
|
|
await route.fulfill({
|
|
json: {
|
|
data: "copy fixture".includes(search.toLowerCase())
|
|
? [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "Copy fixture" }]
|
|
: [],
|
|
},
|
|
})
|
|
})
|
|
await input.fill("copy")
|
|
const project = dialog.getByRole("option", { name: "Copy Project ID", exact: true })
|
|
await expect(project).toBeVisible()
|
|
// Select a non-first command with the keyboard before remote results arrive.
|
|
await input.press("ArrowDown")
|
|
await expect(project).toHaveAttribute("aria-selected", "true")
|
|
files.resolve()
|
|
await expect(dialog.getByRole("option", { name: "/ copy.txt", exact: true })).toBeVisible()
|
|
await expect(project).toHaveAttribute("aria-selected", "true")
|
|
// File results are usable even while sessions are still pending.
|
|
sessions.resolve()
|
|
await expect(dialog.getByRole("option", { name: /Copy fixture/ })).toBeVisible()
|
|
await expect(project).toHaveAttribute("aria-selected", "true")
|
|
await input.fill("copy session")
|
|
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
)
|
|
await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0)
|
|
})
|
|
|
|
test("lists commands above matching sessions and jumps between sections with Alt and the arrows", async ({ page }) => {
|
|
const { dialog, input } = await openCommandPalette(page)
|
|
await page.route("**/api/session?*", (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
data: [
|
|
{
|
|
...paletteSession,
|
|
id: "ses_palette_copy",
|
|
location: { directory: paletteSession.directory },
|
|
title: "Copy fixture",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
)
|
|
await input.fill("copy")
|
|
const session = dialog.getByRole("option", { name: /Copy fixture/ })
|
|
await expect(session).toBeVisible()
|
|
const commands = dialog.getByRole("group", { name: "Commands", exact: true }).getByRole("option")
|
|
await expect(commands.first()).toHaveAttribute("aria-selected", "true")
|
|
await expect(dialog.locator('[data-slot="search-list-header"]').first()).toHaveText("Commands")
|
|
await input.press("Alt+ArrowDown")
|
|
await expect(session).toHaveAttribute("aria-selected", "true")
|
|
await input.press("Alt+ArrowUp")
|
|
await expect(commands.first()).toHaveAttribute("aria-selected", "true")
|
|
await input.press("ArrowDown")
|
|
await input.press("Alt+ArrowUp")
|
|
await expect(commands.first()).toHaveAttribute("aria-selected", "true")
|
|
})
|
|
|
|
test("selects the first session when it arrives above automatically selected files", async ({ page }) => {
|
|
const { dialog, input } = await openCommandPalette(page)
|
|
const sessions = Promise.withResolvers<void>()
|
|
await page.route("**/api/fs/find?*", (route) =>
|
|
route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }),
|
|
)
|
|
await page.route("**/api/session?*", async (route) => {
|
|
await sessions.promise
|
|
await route.fulfill({
|
|
json: {
|
|
data: Array.from({ length: 18 }, (_, index) => ({
|
|
...paletteSession,
|
|
id: index === 0 ? paletteSession.id : `ses_palette_readme_${index}`,
|
|
location: { directory: paletteSession.directory },
|
|
title: `README work ${index + 1}`,
|
|
})),
|
|
},
|
|
})
|
|
})
|
|
await input.fill("README")
|
|
await expect(dialog.getByRole("option", { name: "/ README.md", exact: true })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
)
|
|
sessions.resolve()
|
|
const first = dialog.getByRole("option", { name: /README work 1 command-palette/ })
|
|
const second = dialog.getByRole("option", { name: /README work 2 command-palette/ })
|
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
|
await expect(first).toBeInViewport()
|
|
await input.press("ArrowDown")
|
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
|
await expect(second).toBeInViewport()
|
|
await input.press("ArrowUp")
|
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
|
await input.press("Enter")
|
|
await expect(dialog).toHaveCount(0)
|
|
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
|
})
|
|
|
|
test("keeps a keyboard-selected file when session results arrive later", async ({ page }) => {
|
|
const warnings = captureConsoleWarnings(page)
|
|
const { dialog, input } = await openCommandPalette(page)
|
|
const sessions = Promise.withResolvers<void>()
|
|
await page.route("**/api/fs/find?*", (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
data: [
|
|
{ path: "README.txt", type: "file" },
|
|
{ path: "README.md", type: "file" },
|
|
],
|
|
},
|
|
}),
|
|
)
|
|
await page.route("**/api/session?*", async (route) => {
|
|
await sessions.promise
|
|
await route.fulfill({
|
|
json: {
|
|
data: [{ ...paletteSession, location: { directory: paletteSession.directory }, title: "README work" }],
|
|
},
|
|
})
|
|
})
|
|
await input.fill("README")
|
|
const file = dialog.getByRole("option", { name: "/ README.md", exact: true })
|
|
await expect(dialog.getByRole("option", { name: "/ README.txt", exact: true })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
)
|
|
await input.press("ArrowDown")
|
|
await expect(file).toHaveAttribute("aria-selected", "true")
|
|
sessions.resolve()
|
|
await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible()
|
|
await expect(file).toHaveAttribute("aria-selected", "true")
|
|
await input.press("Enter")
|
|
await expect(dialog).toHaveCount(0)
|
|
const tab = page.getByRole("tab", { name: "README.md", exact: true })
|
|
await expect(tab).toBeVisible()
|
|
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
|
await page
|
|
.getByRole("complementary", { name: "Review and files" })
|
|
.getByRole("button", { name: "Close tab", exact: true })
|
|
.click()
|
|
await expect(tab).toHaveCount(0)
|
|
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
|
// The duplicate command ID warning is DEV-only, so only dev-server runs can catch duplicate tab commands here.
|
|
expect(warnings).toEqual([])
|
|
})
|
|
|
|
test("navigation replaces commands without retaining disposed owners", async ({ page }) => {
|
|
const warnings = captureConsoleWarnings(page)
|
|
const palette = await openCommandPalette(page, true)
|
|
await palette.input.press("Escape")
|
|
await expect(palette.dialog).toHaveCount(0)
|
|
await page
|
|
.getByRole("region", { name: "Recent sessions" })
|
|
.getByRole("button", { name: /Palette fixture session/ })
|
|
.click()
|
|
const editor = page.locator('[data-component="composer-editor"]')
|
|
await expect(editor).toBeEditable()
|
|
await page.keyboard.press("ControlOrMeta+t")
|
|
await expect(page).toHaveURL(/\/new-session\?/)
|
|
await expect(editor).toBeEditable()
|
|
await editor.blur()
|
|
await page.keyboard.press("Control+l")
|
|
await expect(editor).toBeFocused()
|
|
await page.keyboard.press("ControlOrMeta+Shift+P")
|
|
const dialog = page.getByRole("dialog")
|
|
await expect(dialog.getByRole("combobox")).toBeFocused()
|
|
await expect(dialog.getByRole("combobox")).toHaveAttribute("placeholder", "Search files, commands, and sessions")
|
|
await dialog.getByRole("combobox").fill("copy session")
|
|
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveCount(0)
|
|
await dialog.getByRole("combobox").press("Escape")
|
|
await expect(dialog).toHaveCount(0)
|
|
const tabs = page.locator("[data-titlebar-tab-link]")
|
|
await tabs.filter({ hasText: paletteSession.title }).click()
|
|
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
|
|
|
for (const count of [3, 4]) {
|
|
await page.getByRole("button", { name: "New session", exact: true }).click()
|
|
await expect(tabs).toHaveCount(count)
|
|
await expect(editor).toBeEditable()
|
|
}
|
|
|
|
await page.setViewportSize({ width: 600, height: 800 })
|
|
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
|
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-titlebar-tab-link]')).toHaveCount(4)
|
|
await page.setViewportSize({ width: 1280, height: 800 })
|
|
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toHaveCount(4)
|
|
const active = new URL(page.url())
|
|
const closing = page.locator(`[data-titlebar-tab-link][href="${active.pathname}${active.search}"]`)
|
|
await expect(closing).toHaveCount(1)
|
|
await page.keyboard.press("ControlOrMeta+w")
|
|
await expect(tabs).toHaveCount(3)
|
|
await expect(closing).toHaveCount(0)
|
|
await expect(tabs.filter({ hasText: paletteSession.title })).toHaveCount(1)
|
|
await tabs.filter({ hasText: paletteSession.title }).click()
|
|
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
|
await page.keyboard.press("ControlOrMeta+Shift+P")
|
|
await expect(dialog.getByRole("combobox")).toBeFocused()
|
|
await dialog.getByRole("combobox").fill("copy session")
|
|
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
)
|
|
expect(warnings).toEqual([])
|
|
})
|
|
|
|
test("keeps matching session and file results steady across keystrokes while searches are pending", async ({
|
|
page,
|
|
}) => {
|
|
await openPaletteWorkspace(page)
|
|
const mac = await page.evaluate(() => /(Mac|iPod|iPhone|iPad)/.test(navigator.platform))
|
|
await page.keyboard.press(mac ? "Meta+Shift+P" : "Control+Shift+P")
|
|
const dialog = page.getByRole("dialog")
|
|
const input = dialog.getByRole("combobox")
|
|
await expect(input).toBeFocused()
|
|
const sessionOption = dialog.getByRole("option", { name: /Palette fixture session/ })
|
|
const matchingFile = dialog.getByRole("option", { name: "packages/desktop/ palette.ts", exact: true })
|
|
const nonMatchingFile = dialog.getByRole("option", { name: "src/app/ other.ts", exact: true })
|
|
await expect(sessionOption).toBeVisible()
|
|
|
|
const pendingFiles = Promise.withResolvers<void>()
|
|
const pendingSessions = Promise.withResolvers<void>()
|
|
|
|
await page.route("**/api/fs/find?*", async (route) => {
|
|
const query = new URL(route.request().url()).searchParams.get("query") ?? ""
|
|
|
|
if (query !== "p") await pendingFiles.promise
|
|
await route.fulfill({
|
|
json: {
|
|
data:
|
|
query === "p"
|
|
? [
|
|
{ path: "packages/desktop/palette.ts", type: "file" },
|
|
{ path: "src/app/other.ts", type: "file" },
|
|
]
|
|
: [{ path: "packages/desktop/palette.ts", type: "file" }],
|
|
},
|
|
})
|
|
})
|
|
|
|
await page.route("**/api/session?*", async (route) => {
|
|
await pendingSessions.promise
|
|
await route.fulfill({
|
|
json: {
|
|
data: [{ ...paletteSession, location: { directory: paletteSession.directory } }],
|
|
},
|
|
})
|
|
})
|
|
|
|
await input.press("p")
|
|
await expect(matchingFile).toBeVisible()
|
|
await expect(nonMatchingFile).toBeVisible()
|
|
await expect(sessionOption).toBeVisible()
|
|
|
|
const probe = await page.evaluateHandle(() => {
|
|
const results = document.querySelector('[role="dialog"] [data-slot="search-list-scroll"]')!
|
|
|
|
const state = {
|
|
emptyFrames: 0,
|
|
initialSessionEl:
|
|
Array.from(results.querySelectorAll('[role="option"]')).find((el) =>
|
|
el.textContent?.includes("Palette fixture session"),
|
|
) ?? null,
|
|
initialFileEl:
|
|
Array.from(results.querySelectorAll('[role="option"]')).find((el) => el.textContent?.includes("palette.ts")) ??
|
|
null,
|
|
}
|
|
|
|
const observer = new MutationObserver(() => {
|
|
if (
|
|
results.querySelectorAll('[role="option"]').length === 0 ||
|
|
results.querySelector('[data-slot="search-list-empty"]')
|
|
) {
|
|
state.emptyFrames++
|
|
}
|
|
})
|
|
|
|
observer.observe(results, { childList: true, subtree: true })
|
|
|
|
return {
|
|
finish() {
|
|
observer.disconnect()
|
|
|
|
const currentSessionEl = Array.from(results.querySelectorAll('[role="option"]')).find((el) =>
|
|
el.textContent?.includes("Palette fixture session"),
|
|
)
|
|
|
|
const currentFileEl = Array.from(results.querySelectorAll('[role="option"]')).find((el) =>
|
|
el.textContent?.includes("palette.ts"),
|
|
)
|
|
|
|
return {
|
|
emptyFrames: state.emptyFrames,
|
|
sameSessionNode: Boolean(state.initialSessionEl && state.initialSessionEl === currentSessionEl),
|
|
sameFileNode: Boolean(state.initialFileEl && state.initialFileEl === currentFileEl),
|
|
}
|
|
},
|
|
}
|
|
})
|
|
|
|
for (const key of "alette") {
|
|
await input.press(key)
|
|
await expect(sessionOption).toBeVisible()
|
|
await expect(matchingFile).toBeVisible()
|
|
await expect(nonMatchingFile).toHaveCount(0)
|
|
}
|
|
|
|
await expect(sessionOption).toHaveAttribute("aria-selected", "true")
|
|
|
|
pendingFiles.resolve()
|
|
pendingSessions.resolve()
|
|
|
|
await expect(sessionOption).toBeVisible()
|
|
await expect(matchingFile).toBeVisible()
|
|
|
|
const stability = await probe.evaluate((tracker) => tracker.finish())
|
|
|
|
expect(stability).toEqual({
|
|
emptyFrames: 0,
|
|
sameSessionNode: true,
|
|
sameFileNode: true,
|
|
})
|
|
})
|
|
|
|
test("titlebar session search shrinks with results while its field stays anchored through scrolling", async ({
|
|
page,
|
|
}) => {
|
|
await openPaletteWorkspace(page, true, [
|
|
{ ...paletteSession, time: { ...paletteSession.time } },
|
|
...Array.from({ length: 23 }, (_, index) => ({
|
|
...paletteSession,
|
|
id: `ses_compact_palette_${index}`,
|
|
title: index === 0 ? "Compact keeper needle" : `Compact keeper result ${index}`,
|
|
time: { created: 1700000000000 - index - 1, updated: 1700000000000 - index - 1 },
|
|
})),
|
|
])
|
|
await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click()
|
|
const dialog = page.getByRole("dialog")
|
|
const input = dialog.getByRole("combobox")
|
|
const options = dialog.getByRole("option")
|
|
const container = page.locator('[data-component="dialog-v2"] [data-slot="dialog-container"]')
|
|
|
|
const geometry = () =>
|
|
container.evaluate((element) => ({
|
|
height: element.getBoundingClientRect().height,
|
|
inputTop: element.querySelector('[role="combobox"]')!.getBoundingClientRect().top,
|
|
}))
|
|
|
|
await expect(input).toBeFocused()
|
|
await expect(options).toHaveCount(10)
|
|
// Hover waits for the field's opening animation to settle before taking the reference position.
|
|
await input.hover()
|
|
const initial = await geometry()
|
|
expect(initial.height).toBeGreaterThan(200)
|
|
|
|
await input.fill("Compact keeper needle")
|
|
await expect(options).toHaveText([/Compact keeper needle/])
|
|
await expect.poll(async () => (await geometry()).height).toBeLessThan(initial.height - 100)
|
|
await expect.poll(async () => (await geometry()).inputTop).toBeCloseTo(initial.inputTop, 0)
|
|
|
|
await input.clear()
|
|
await expect(options).toHaveCount(10)
|
|
await expect.poll(geometry).toEqual({
|
|
height: expect.closeTo(initial.height, 0),
|
|
inputTop: expect.closeTo(initial.inputTop, 0),
|
|
})
|
|
|
|
// A broader query exposes more than the recent-session limit and must scroll below the field.
|
|
await input.fill("Compact keeper")
|
|
await expect(options).toHaveCount(23)
|
|
const scroll = dialog.locator('[data-slot="search-list-scroll"]')
|
|
await expect.poll(() => scroll.evaluate((element) => element.scrollHeight - element.clientHeight)).toBeGreaterThan(0)
|
|
await expect.poll(async () => (await geometry()).height).toBeLessThanOrEqual(480)
|
|
const last = dialog.getByRole("option", { name: /Compact keeper result 22/ })
|
|
await expect(last).not.toBeInViewport()
|
|
await scroll.hover()
|
|
await page.mouse.wheel(0, 2000)
|
|
await expect(last).toBeInViewport()
|
|
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
|
await expect.poll(async () => (await geometry()).inputTop).toBeCloseTo(initial.inputTop, 0)
|
|
await expect(input).toBeFocused()
|
|
})
|