Files

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()
})