diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index df8e0768ed..1b44138784 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -602,12 +602,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) { } export async function openSharePopover(page: Page) { - const rightSection = page.locator(titlebarRightSelector) - const shareButton = rightSection.getByRole("button", { name: "Share" }).first() - await expect(shareButton).toBeVisible() + const scroller = page.locator(".scroll-view__viewport").first() + await expect(scroller).toBeVisible() + await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) + + const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first() + await expect(menuTrigger).toBeVisible({ timeout: 30_000 }) const popoverBody = page - .locator(popoverBodySelector) + .locator('[data-component="popover-content"]') .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) }) .first() @@ -617,10 +620,13 @@ export async function openSharePopover(page: Page) { .catch(() => false) if (!opened) { - await shareButton.click() - await expect(popoverBody).toBeVisible() + const menu = page.locator(dropdownMenuContentSelector).first() + await menuTrigger.click() + await clickMenuItem(menu, /share/i) + await expect(menu).toHaveCount(0) + await expect(popoverBody).toBeVisible({ timeout: 30_000 }) } - return { rightSection, popoverBody } + return { rightSection: scroller, popoverBody } } export async function clickPopoverButton(page: Page, buttonName: string | RegExp) { @@ -1005,30 +1011,57 @@ export async function openProjectMenu(page: Page, projectSlug: string) { } export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) { - const current = await page - .getByRole("button", { name: "New workspace" }) - .first() - .isVisible() - .then((x) => x) - .catch(() => false) + const current = () => + page + .getByRole("button", { name: "New workspace" }) + .first() + .isVisible() + .then((x) => x) + .catch(() => false) - if (current === enabled) return + if ((await current()) === enabled) return + + if (enabled) { + await page.goto(page.url()) + await openSidebar(page) + if ((await current()) === enabled) return + } const flip = async (timeout?: number) => { const menu = await openProjectMenu(page, projectSlug) const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first() await expect(toggle).toBeVisible() - return toggle.click({ force: true, timeout }) + await expect(toggle).toBeEnabled({ timeout: 30_000 }) + const clicked = await toggle + .click({ force: true, timeout }) + .then(() => true) + .catch(() => false) + if (clicked) return + await toggle.focus() + await page.keyboard.press("Enter") } - const flipped = await flip(1500) - .then(() => true) - .catch(() => false) + for (const timeout of [1500, undefined, undefined]) { + if ((await current()) === enabled) break + await flip(timeout) + .then(() => undefined) + .catch(() => undefined) + const matched = await expect + .poll(current, { timeout: 5_000 }) + .toBe(enabled) + .then(() => true) + .catch(() => false) + if (matched) break + } - if (!flipped) await flip() + if ((await current()) !== enabled) { + await page.goto(page.url()) + await openSidebar(page) + } const expected = enabled ? "New workspace" : "New session" - await expect(page.getByRole("button", { name: expected }).first()).toBeVisible() + await expect.poll(current, { timeout: 60_000 }).toBe(enabled) + await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 }) } export async function openWorkspaceMenu(page: Page, workspaceSlug: string) { diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index d34caf1331..77a609c45d 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -16,10 +16,11 @@ import { waitSessionIdle, waitSessionSaved, waitSlug, + withNoReplyPrompt, } from "./actions" import { openaiModel, withMockOpenAI } from "./prompt/mock" import { promptSelector } from "./selectors" -import { createSdk, dirSlug, getWorktree, resolveDirectory, sessionPath } from "./utils" +import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" type LLMFixture = { url: string @@ -87,6 +88,21 @@ function clean(value: string | null) { return (value ?? "").replace(/\u200B/g, "").trim() } +async function promptSend(page: Page) { + return page + .evaluate(() => { + const win = window as E2EWindow + const sent = win.__opencode_e2e?.prompt?.sent + return { + started: sent?.started ?? 0, + count: sent?.count ?? 0, + sessionID: sent?.sessionID, + directory: sent?.directory, + } + }) + .catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined })) +} + type ProjectHandle = { directory: string slug: string @@ -106,6 +122,8 @@ type ProjectOptions = { type ProjectFixture = ProjectHandle & { open: (options?: ProjectOptions) => Promise prompt: (text: string) => Promise + user: (text: string) => Promise + shell: (cmd: string) => Promise } type TestFixtures = { @@ -345,7 +363,7 @@ function makeProject( await seedStorage(page, { directory, extra: options?.extra, - model: options?.model ?? openaiModel, + model: options?.model, serverUrl: backend.url, }) state = { @@ -360,49 +378,87 @@ function makeProject( need().slug = await waitSlug(page) } - const prompt = async (text: string) => { - const cur = need() - if ((await llm.pending()) === 0) { + const send = async (text: string, input: { noReply: boolean; shell: boolean }) => { + const prev = await promptSend(page) + if (!input.noReply && !input.shell && (await llm.pending()) === 0) { await llm.text("ok") } const prompt = page.locator(promptSelector).first() - await expect(prompt).toBeVisible() - await prompt.click() - await page.keyboard.type(text) - await expect.poll(async () => clean(await prompt.textContent())).toBe(text) - await page.keyboard.press("Enter") - const sent = await expect - .poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 5_000 }) - .not.toBe("") - .then(() => true) - .catch(() => false) - if (!sent) { + const submit = async () => { + await expect(prompt).toBeVisible() + await prompt.click() + if (input.shell) { + await page.keyboard.type("!") + await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i) + } + await page.keyboard.type(text) + await expect.poll(async () => clean(await prompt.textContent())).toBe(text) + await page.keyboard.press("Enter") + const started = await expect + .poll(async () => (await promptSend(page)).started, { timeout: 5_000 }) + .toBeGreaterThan(prev.started) + .then(() => true) + .catch(() => false) + if (started) return const send = page.getByRole("button", { name: "Send" }).first() - await expect(send).toBeEnabled() - await send.click() + const enabled = await send + .isEnabled() + .then((x) => x) + .catch(() => false) + if (enabled) { + await send.click() + } else { + await prompt.click() + await page.keyboard.press("Enter") + } + await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started) } - await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 90_000 }) - const sessionID = sessionIDFromUrl(page.url()) - if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) + if (input.noReply) { + await withNoReplyPrompt(page, submit) + } else { + await submit() + } - const current = await page - .evaluate(() => { - const win = window as E2EWindow - const next = win.__opencode_e2e?.model?.current - if (!next) return null - return { dir: next.dir, sessionID: next.sessionID } - }) - .catch(() => null as { dir?: string; sessionID?: string } | null) - const directory = current?.dir - ? await resolveDirectory(current.dir, backend.url).catch(() => cur.directory) - : cur.directory + let next: { sessionID: string; directory: string } | undefined + await expect + .poll( + async () => { + const sent = await promptSend(page) + if (sent.count <= prev.count) return "" + if (!sent.sessionID || !sent.directory) return "" + next = { sessionID: sent.sessionID, directory: sent.directory } + return sent.sessionID + }, + { timeout: 90_000 }, + ) + .not.toBe("") - trackSession(sessionID, directory) - await waitSessionSaved(directory, sessionID, 90_000, backend.url) - await waitSessionIdle(backend.sdk(directory), sessionID, 90_000).catch(() => undefined) - return sessionID + if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe") + const active = await waitSession(page, { + directory: next.directory, + sessionID: next.sessionID, + serverUrl: backend.url, + }) + trackSession(next.sessionID, active.directory) + if (!input.shell) { + await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url) + } + await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined) + return next.sessionID + } + + const prompt = async (text: string) => { + return send(text, { noReply: false, shell: false }) + } + + const user = async (text: string) => { + return send(text, { noReply: true, shell: false }) + } + + const shell = async (cmd: string) => { + return send(cmd, { noReply: false, shell: true }) } const cleanup = async () => { @@ -424,6 +480,8 @@ function makeProject( project: { open, prompt, + user, + shell, gotoSession, trackSession, trackDirectory, diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts index 7c20f29ec1..bc182a6953 100644 --- a/packages/app/e2e/projects/project-edit.spec.ts +++ b/packages/app/e2e/projects/project-edit.spec.ts @@ -1,43 +1,47 @@ import { test, expect } from "../fixtures" import { clickMenuItem, openProjectMenu, openSidebar } from "../actions" -test("dialog edit project updates name and startup script", async ({ page, withProject }) => { +test("dialog edit project updates name and startup script", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async ({ slug }) => { - await openSidebar(page) + await project.open() + await openSidebar(page) - const open = async () => { - const menu = await openProjectMenu(page, slug) - await clickMenuItem(menu, /^Edit$/i, { force: true }) + const open = async () => { + const menu = await openProjectMenu(page, project.slug) + await clickMenuItem(menu, /^Edit$/i, { force: true }) - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") - return dialog - } + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") + return dialog + } - const name = `e2e project ${Date.now()}` - const startup = `echo e2e_${Date.now()}` + const name = `e2e project ${Date.now()}` + const startup = `echo e2e_${Date.now()}` - const dialog = await open() + const dialog = await open() - const nameInput = dialog.getByLabel("Name") - await nameInput.fill(name) + const nameInput = dialog.getByLabel("Name") + await nameInput.fill(name) - const startupInput = dialog.getByLabel("Workspace startup script") - await startupInput.fill(startup) + const startupInput = dialog.getByLabel("Workspace startup script") + await startupInput.fill(startup) - await dialog.getByRole("button", { name: "Save" }).click() - await expect(dialog).toHaveCount(0) + await dialog.getByRole("button", { name: "Save" }).click() + await expect(dialog).toHaveCount(0) - const header = page.locator(".group\\/project").first() - await expect(header).toContainText(name) - - const reopened = await open() - await expect(reopened.getByLabel("Name")).toHaveValue(name) - await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup) - await reopened.getByRole("button", { name: "Cancel" }).click() - await expect(reopened).toHaveCount(0) - }) + await expect + .poll( + async () => { + const reopened = await open() + const value = await reopened.getByLabel("Name").inputValue() + const next = await reopened.getByLabel("Workspace startup script").inputValue() + await reopened.getByRole("button", { name: "Cancel" }).click() + await expect(reopened).toHaveCount(0) + return `${value}\n${next}` + }, + { timeout: 30_000 }, + ) + .toBe(`${name}\n${startup}`) }) diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts index 9454d683f0..75e6f2ce68 100644 --- a/packages/app/e2e/projects/projects-close.spec.ts +++ b/packages/app/e2e/projects/projects-close.spec.ts @@ -3,51 +3,46 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open import { projectSwitchSelector } from "../selectors" import { dirSlug } from "../utils" -test("closing active project navigates to another open project", async ({ page, withProject }) => { +test("closing active project navigates to another open project", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherSlug = dirSlug(other) try { - await withProject( - async ({ slug }) => { - await openSidebar(page) + await project.open({ extra: [other] }) + await openSidebar(page) - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - const menu = await openProjectMenu(page, otherSlug) + const menu = await openProjectMenu(page, otherSlug) + await clickMenuItem(menu, /^Close$/i, { force: true }) - await clickMenuItem(menu, /^Close$/i, { force: true }) + await expect + .poll( + () => { + const pathname = new URL(page.url()).pathname + if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project" + if (pathname === "/") return "home" + return "" + }, + { timeout: 15_000 }, + ) + .toMatch(/^(project|home)$/) - await expect - .poll( - () => { - const pathname = new URL(page.url()).pathname - if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project" - if (pathname === "/") return "home" - return "" - }, - { timeout: 15_000 }, - ) - .toMatch(/^(project|home)$/) - - await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`)) - await expect - .poll( - async () => { - return await page.locator(projectSwitchSelector(otherSlug)).count() - }, - { timeout: 15_000 }, - ) - .toBe(0) - }, - { extra: [other] }, - ) + await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`)) + await expect + .poll( + async () => { + return await page.locator(projectSwitchSelector(otherSlug)).count() + }, + { timeout: 15_000 }, + ) + .toBe(0) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index f87a47cf09..67d09afd15 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -5,114 +5,89 @@ import { createTestProject, cleanupTestProject, openSidebar, - sessionIDFromUrl, setWorkspacesEnabled, waitSession, - waitSessionSaved, waitSlug, - withNoReplyPrompt, } from "../actions" -import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" +import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" -test("can switch between projects from sidebar", async ({ page, withProject }) => { +test("can switch between projects from sidebar", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherSlug = dirSlug(other) try { - await withProject( - async ({ directory }) => { - await defocus(page) + await project.open({ extra: [other] }) + await defocus(page) - const currentSlug = dirSlug(directory) - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() + const currentSlug = dirSlug(project.directory) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() - await expect(currentButton).toBeVisible() - await currentButton.click() + const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() + await expect(currentButton).toBeVisible() + await currentButton.click() - await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) - }, - { extra: [other] }, - ) + await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) } finally { await cleanupTestProject(other) } }) -test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => { +test("switching back to a project opens the latest workspace session", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherSlug = dirSlug(other) try { - await withProject( - async ({ directory, slug, trackSession, trackDirectory }) => { - await defocus(page) - await setWorkspacesEnabled(page, slug, true) - await openSidebar(page) - await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + await project.open({ extra: [other] }) + await defocus(page) + await setWorkspacesEnabled(page, project.slug, true) + await openSidebar(page) + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() - await page.getByRole("button", { name: "New workspace" }).first().click() + await page.getByRole("button", { name: "New workspace" }).first().click() - const raw = await waitSlug(page, [slug]) - const dir = base64Decode(raw) - if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`) - const space = await resolveDirectory(dir) - const next = dirSlug(space) - trackDirectory(space) - await openSidebar(page) + const raw = await waitSlug(page, [project.slug]) + const dir = base64Decode(raw) + if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`) + const space = await resolveDirectory(dir) + const next = dirSlug(space) + project.trackDirectory(space) + await openSidebar(page) - const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() - await expect(item).toBeVisible() - await item.hover() + const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() + await expect(item).toBeVisible() + await item.hover() - const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() - await expect(btn).toBeVisible() - await btn.click({ force: true }) + const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() + await expect(btn).toBeVisible() + await btn.click({ force: true }) - await waitSession(page, { directory: space }) + await waitSession(page, { directory: space }) - // Create a session by sending a prompt - const prompt = page.locator(promptSelector) - await expect(prompt).toBeVisible() - await withNoReplyPrompt(page, async () => { - await prompt.fill("test") - await page.keyboard.press("Enter") - }) + const created = await project.user("test") - // Wait for the URL to update with the new session ID - await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("") + await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) - const created = sessionIDFromUrl(page.url()) - if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`) - trackSession(created, space) - await waitSessionSaved(space, created) + await openSidebar(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`)) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click({ force: true }) + await waitSession(page, { directory: other }) - await openSidebar(page) + const rootButton = page.locator(projectSwitchSelector(project.slug)).first() + await expect(rootButton).toBeVisible() + await rootButton.click({ force: true }) - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click({ force: true }) - await waitSession(page, { directory: other }) - - const rootButton = page.locator(projectSwitchSelector(slug)).first() - await expect(rootButton).toBeVisible() - await rootButton.click({ force: true }) - - await waitSession(page, { directory: space, sessionID: created }) - await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) - }, - { extra: [other] }, - ) + await waitSession(page, { directory: space, sessionID: created }) + await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index 835c8c99ed..d9d010b4dc 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -7,12 +7,9 @@ import { setWorkspacesEnabled, waitDir, waitSession, - waitSessionSaved, waitSlug, - withNoReplyPrompt, } from "../actions" -import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" -import { createSdk } from "../utils" +import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" function item(space: { slug: string; raw: string }) { return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}` @@ -51,47 +48,31 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s } async function createSessionFromWorkspace( + project: Parameters[0]["project"], page: Page, space: { slug: string; raw: string; directory: string }, text: string, ) { await openWorkspaceNewSession(page, space) - - const prompt = page.locator(promptSelector) - await expect(prompt).toBeVisible() - await withNoReplyPrompt(page, async () => { - await prompt.fill(text) - await page.keyboard.press("Enter") - }) - - await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("") - const sessionID = sessionIDFromUrl(page.url()) - if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) - - await waitSessionSaved(space.directory, sessionID) - await createSdk(space.directory) - .session.abort({ sessionID }) - .catch(() => undefined) - return sessionID + return project.user(text) } -test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => { +test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async ({ slug: root, trackDirectory, trackSession }) => { - await openSidebar(page) - await setWorkspacesEnabled(page, root, true) + await project.open() + await openSidebar(page) + await setWorkspacesEnabled(page, project.slug, true) - const first = await createWorkspace(page, root, []) - trackDirectory(first.directory) - await waitWorkspaceReady(page, first) + const first = await createWorkspace(page, project.slug, []) + project.trackDirectory(first.directory) + await waitWorkspaceReady(page, first) - const second = await createWorkspace(page, root, [first.slug]) - trackDirectory(second.directory) - await waitWorkspaceReady(page, second) + const second = await createWorkspace(page, project.slug, [first.slug]) + project.trackDirectory(second.directory) + await waitWorkspaceReady(page, second) - trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory) - trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory) - trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory) - }) + await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`) + await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`) + await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`) }) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 297cdb9fc9..16caa3d496 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -20,9 +20,9 @@ import { waitSlug, } from "../actions" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" -import { createSdk, dirSlug } from "../utils" +import { dirSlug } from "../utils" -async function setupWorkspaceTest(page: Page, project: { slug: string }) { +async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) { const rootSlug = project.slug await openSidebar(page) @@ -31,6 +31,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { await page.getByRole("button", { name: "New workspace" }).first().click() const next = await resolveSlug(await waitSlug(page, [rootSlug])) await waitDir(page, next.directory) + project.trackDirectory(next.directory) await openSidebar(page) @@ -52,44 +53,206 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { return { rootSlug, slug: next.slug, directory: next.directory } } -test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { +test("can enable and disable workspaces from project menu", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) + await project.open() - await withProject(async ({ slug }) => { - await openSidebar(page) + await openSidebar(page) - await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() - await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) + await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() + await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - await setWorkspacesEnabled(page, slug, true) - await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() - await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible() + await setWorkspacesEnabled(page, project.slug, true) + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible() - await setWorkspacesEnabled(page, slug, false) - await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() - await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) - }) + await setWorkspacesEnabled(page, project.slug, false) + await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0) }) -test("can create a workspace", async ({ page, withProject }) => { +test("can create a workspace", async ({ page, project }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + await project.open() + + await openSidebar(page) + await setWorkspacesEnabled(page, project.slug, true) + + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + + await page.getByRole("button", { name: "New workspace" }).first().click() + const next = await resolveSlug(await waitSlug(page, [project.slug])) + await waitDir(page, next.directory) + project.trackDirectory(next.directory) + + await openSidebar(page) + + await expect + .poll( + async () => { + const item = page.locator(workspaceItemSelector(next.slug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } + }, + { timeout: 60_000 }, + ) + .toBe(true) + + await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() +}) + +test("non-git projects keep workspace mode disabled", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async ({ slug }) => { - await openSidebar(page) - await setWorkspacesEnabled(page, slug, true) + const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-")) + const nonGitSlug = dirSlug(nonGit) - await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n") - await page.getByRole("button", { name: "New workspace" }).first().click() - const next = await resolveSlug(await waitSlug(page, [slug])) - await waitDir(page, next.directory) + try { + await project.open({ extra: [nonGit] }) + await page.goto(`/${nonGitSlug}/session`) + + await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") + + const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory) + expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") await openSidebar(page) + await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) + const trigger = page.locator('[data-action="project-menu"]').first() + const hasMenu = await trigger + .isVisible() + .then((x) => x) + .catch(() => false) + if (!hasMenu) return + + const menu = await openProjectMenu(page, nonGitSlug) + + const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first() + + await expect(toggle).toBeVisible() + await expect(toggle).toBeDisabled() + await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0) + } finally { + await cleanupTestProject(nonGit) + } +}) + +test("can rename a workspace", async ({ page, project }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + await project.open() + + const { slug } = await setupWorkspaceTest(page, project) + + const rename = `e2e workspace ${Date.now()}` + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Rename$/i, { force: true }) + + await expect(menu).toHaveCount(0) + + const item = page.locator(workspaceItemSelector(slug)).first() + await expect(item).toBeVisible() + const input = item.locator(inlineInputSelector).first() + const shown = await input + .isVisible() + .then((x) => x) + .catch(() => false) + if (!shown) { + const retry = await openWorkspaceMenu(page, slug) + await clickMenuItem(retry, /^Rename$/i, { force: true }) + await expect(retry).toHaveCount(0) + } + await expect(input).toBeVisible() + await input.fill(rename) + await input.press("Enter") + await expect(item).toContainText(rename) +}) + +test("can reset a workspace", async ({ page, project }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + await project.open() + + const { slug, directory: createdDir } = await setupWorkspaceTest(page, project) + + const readme = path.join(createdDir, "README.md") + const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) + const original = await fs.readFile(readme, "utf8") + const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n` + await fs.writeFile(readme, dirty, "utf8") + await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8") + + await expect + .poll(async () => { + return await fs + .stat(extra) + .then(() => true) + .catch(() => false) + }) + .toBe(true) + + await expect + .poll(async () => { + const files = await project.sdk.file + .status({ directory: createdDir }) + .then((r) => r.data ?? []) + .catch(() => []) + return files.length + }) + .toBeGreaterThan(0) + + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Reset$/i, { force: true }) + await confirmDialog(page, /^Reset workspace$/i) + + await expect + .poll( + async () => { + const files = await project.sdk.file + .status({ directory: createdDir }) + .then((r) => r.data ?? []) + .catch(() => []) + return files.length + }, + { timeout: 120_000 }, + ) + .toBe(0) + + await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original) + + await expect + .poll(async () => { + return await fs + .stat(extra) + .then(() => true) + .catch(() => false) + }) + .toBe(false) +}) + +test("can reorder workspaces by drag and drop", async ({ page, project }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + await project.open() + const rootSlug = project.slug + + const listSlugs = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + return slugs + } + + const waitReady = async (slug: string) => { await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(next.slug)).first() + const item = page.locator(workspaceItemSelector(slug)).first() try { await item.hover({ timeout: 500 }) return true @@ -100,276 +263,107 @@ test("can create a workspace", async ({ page, withProject }) => { { timeout: 60_000 }, ) .toBe(true) - - await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() - - await cleanupTestProject(next.directory) - }) -}) - -test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-")) - const nonGitSlug = dirSlug(nonGit) - - await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n") - - try { - await withProject(async () => { - await page.goto(`/${nonGitSlug}/session`) - - await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") - - const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory) - expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") - - await openSidebar(page) - await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - - const trigger = page.locator('[data-action="project-menu"]').first() - const hasMenu = await trigger - .isVisible() - .then((x) => x) - .catch(() => false) - if (!hasMenu) return - - await trigger.click({ force: true }) - - const menu = page.locator(dropdownMenuContentSelector).first() - await expect(menu).toBeVisible() - - const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first() - - await expect(toggle).toBeVisible() - await expect(toggle).toBeDisabled() - await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0) - }) - } finally { - await cleanupTestProject(nonGit) } -}) -test("can rename a workspace", async ({ page, withProject }) => { - await page.setViewportSize({ width: 1400, height: 800 }) + const drag = async (from: string, to: string) => { + const src = page.locator(workspaceItemSelector(from)).first() + const dst = page.locator(workspaceItemSelector(to)).first() - await withProject(async (project) => { - const { slug } = await setupWorkspaceTest(page, project) + const a = await src.boundingBox() + const b = await dst.boundingBox() + if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") - const rename = `e2e workspace ${Date.now()}` - const menu = await openWorkspaceMenu(page, slug) - await clickMenuItem(menu, /^Rename$/i, { force: true }) + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) + await page.mouse.down() + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) + await page.mouse.up() + } - await expect(menu).toHaveCount(0) + await openSidebar(page) - const item = page.locator(workspaceItemSelector(slug)).first() - await expect(item).toBeVisible() - const input = item.locator(inlineInputSelector).first() - await expect(input).toBeVisible() - await input.fill(rename) - await input.press("Enter") - await expect(item).toContainText(rename) - }) -}) + await setWorkspacesEnabled(page, rootSlug, true) -test("can reset a workspace", async ({ page, sdk, withProject }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - await withProject(async (project) => { - const { slug, directory: createdDir } = await setupWorkspaceTest(page, project) - - const readme = path.join(createdDir, "README.md") - const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) - const original = await fs.readFile(readme, "utf8") - const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n` - await fs.writeFile(readme, dirty, "utf8") - await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8") - - await expect - .poll(async () => { - return await fs - .stat(extra) - .then(() => true) - .catch(() => false) - }) - .toBe(true) - - await expect - .poll(async () => { - const files = await sdk.file - .status({ directory: createdDir }) - .then((r) => r.data ?? []) - .catch(() => []) - return files.length - }) - .toBeGreaterThan(0) - - const menu = await openWorkspaceMenu(page, slug) - await clickMenuItem(menu, /^Reset$/i, { force: true }) - await confirmDialog(page, /^Reset workspace$/i) - - await expect - .poll( - async () => { - const files = await sdk.file - .status({ directory: createdDir }) - .then((r) => r.data ?? []) - .catch(() => []) - return files.length - }, - { timeout: 60_000 }, - ) - .toBe(0) - - await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original) - - await expect - .poll(async () => { - return await fs - .stat(extra) - .then(() => true) - .catch(() => false) - }) - .toBe(false) - }) -}) - -test("can delete a workspace", async ({ page, withProject }) => { - await page.setViewportSize({ width: 1400, height: 800 }) - - await withProject(async (project) => { - const sdk = createSdk(project.directory) - const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project) - - await expect - .poll( - async () => { - const worktrees = await sdk.worktree - .list() - .then((r) => r.data ?? []) - .catch(() => [] as string[]) - return worktrees.includes(directory) - }, - { timeout: 30_000 }, - ) - .toBe(true) - - const menu = await openWorkspaceMenu(page, slug) - await clickMenuItem(menu, /^Delete$/i, { force: true }) - await confirmDialog(page, /^Delete workspace$/i) - - await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory) - - await expect - .poll( - async () => { - const worktrees = await sdk.worktree - .list() - .then((r) => r.data ?? []) - .catch(() => [] as string[]) - return worktrees.includes(directory) - }, - { timeout: 60_000 }, - ) - .toBe(false) - - await project.gotoSession() + const workspaces = [] as { directory: string; slug: string }[] + for (const _ of [0, 1]) { + const prev = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) + await waitDir(page, next.directory) + project.trackDirectory(next.directory) + workspaces.push(next) await openSidebar(page) - await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 }) - await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() - }) + } + + if (workspaces.length !== 2) throw new Error("Expected two created workspaces") + + const a = workspaces[0].slug + const b = workspaces[1].slug + + await waitReady(a) + await waitReady(b) + + const list = async () => { + const slugs = await listSlugs() + return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) + } + + await expect + .poll(async () => { + const slugs = await list() + return slugs.length === 2 + }) + .toBe(true) + + const before = await list() + const from = before[1] + const to = before[0] + if (!from || !to) throw new Error("Failed to resolve initial workspace order") + + await drag(from, to) + + await expect.poll(async () => await list()).toEqual([from, to]) }) -test("can reorder workspaces by drag and drop", async ({ page, withProject }) => { +test("can delete a workspace", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await withProject(async ({ slug: rootSlug }) => { - const workspaces = [] as { directory: string; slug: string }[] + await project.open() - const listSlugs = async () => { - const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') - const slugs = await nodes.evaluateAll((els) => { - return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) - }) - return slugs - } + const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project) - const waitReady = async (slug: string) => { - await expect - .poll( - async () => { - const item = page.locator(workspaceItemSelector(slug)).first() - try { - await item.hover({ timeout: 500 }) - return true - } catch { - return false - } - }, - { timeout: 60_000 }, - ) - .toBe(true) - } + await expect + .poll( + async () => { + const worktrees = await project.sdk.worktree + .list() + .then((r) => r.data ?? []) + .catch(() => [] as string[]) + return worktrees.includes(directory) + }, + { timeout: 30_000 }, + ) + .toBe(true) - const drag = async (from: string, to: string) => { - const src = page.locator(workspaceItemSelector(from)).first() - const dst = page.locator(workspaceItemSelector(to)).first() + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Delete$/i, { force: true }) + await confirmDialog(page, /^Delete workspace$/i) - const a = await src.boundingBox() - const b = await dst.boundingBox() - if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") + await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory) - await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) - await page.mouse.down() - await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) - await page.mouse.up() - } + await expect + .poll( + async () => { + const worktrees = await project.sdk.worktree + .list() + .then((r) => r.data ?? []) + .catch(() => [] as string[]) + return worktrees.includes(directory) + }, + { timeout: 60_000 }, + ) + .toBe(false) - try { - await openSidebar(page) - - await setWorkspacesEnabled(page, rootSlug, true) - - for (const _ of [0, 1]) { - const prev = slugFromUrl(page.url()) - await page.getByRole("button", { name: "New workspace" }).first().click() - const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) - await waitDir(page, next.directory) - workspaces.push(next) - - await openSidebar(page) - } - - if (workspaces.length !== 2) throw new Error("Expected two created workspaces") - - const a = workspaces[0].slug - const b = workspaces[1].slug - - await waitReady(a) - await waitReady(b) - - const list = async () => { - const slugs = await listSlugs() - return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) - } - - await expect - .poll(async () => { - const slugs = await list() - return slugs.length === 2 - }) - .toBe(true) - - const before = await list() - const from = before[1] - const to = before[0] - if (!from || !to) throw new Error("Failed to resolve initial workspace order") - - await drag(from, to) - - await expect.poll(async () => await list()).toEqual([from, to]) - } finally { - await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory))) - } - }) + await openSidebar(page) + await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 }) + await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() }) diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts index 7c39a2db34..d81f1d4c40 100644 --- a/packages/app/e2e/prompt/prompt-shell.spec.ts +++ b/packages/app/e2e/prompt/prompt-shell.spec.ts @@ -1,7 +1,6 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client" import { test, expect } from "../fixtures" -import { sessionIDFromUrl } from "../actions" -import { promptSelector } from "../selectors" +import { withSession } from "../actions" const isBash = (part: unknown): part is ToolPart => { if (!part || typeof part !== "object") return false @@ -10,33 +9,35 @@ const isBash = (part: unknown): part is ToolPart => { return "state" in part } -test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => { +async function setAutoAccept(page: Parameters[0]["page"], enabled: boolean) { + const button = page.locator('[data-action="prompt-permissions"]').first() + await expect(button).toBeVisible() + const pressed = (await button.getAttribute("aria-pressed")) === "true" + if (pressed === enabled) return + await button.click() + await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false") +} + +test("shell mode runs a command in the project directory", async ({ page, project }) => { test.setTimeout(120_000) - await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => { - const prompt = page.locator(promptSelector) - const cmd = process.platform === "win32" ? "dir" : "command ls" + await project.open() + const cmd = process.platform === "win32" ? "dir" : "command ls" - await gotoSession() - await prompt.click() - await page.keyboard.type("!") - await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i) - - await page.keyboard.type(cmd) - await page.keyboard.press("Enter") - - await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) - - const id = sessionIDFromUrl(page.url()) - if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`) - trackSession(id, directory) + await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) + await setAutoAccept(page, true) + await project.shell(cmd) await expect .poll( async () => { - const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? []) + const list = await project.sdk.session + .messages({ sessionID: session.id, limit: 50 }) + .then((x) => x.data ?? []) const msg = list.findLast( - (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory, + (item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory, ) if (!msg) return @@ -49,12 +50,10 @@ test("shell mode runs a command in the project directory", async ({ page, withBa typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output if (!output.includes("README.md")) return - return { cwd: directory, output } + return { cwd: project.directory, output } }, { timeout: 90_000 }, ) - .toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") })) - - await expect(prompt).toHaveText("") + .toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") })) }) }) diff --git a/packages/app/e2e/prompt/prompt-slash-share.spec.ts b/packages/app/e2e/prompt/prompt-slash-share.spec.ts index 5371d8a918..f3eeceee5f 100644 --- a/packages/app/e2e/prompt/prompt-slash-share.spec.ts +++ b/packages/app/e2e/prompt/prompt-slash-share.spec.ts @@ -22,46 +22,45 @@ async function seed(sdk: Parameters[0], sessionID: string) { .toBeGreaterThan(0) } -test("/share and /unshare update session share state", async ({ page, withBackendProject }) => { +test("/share and /unshare update session share state", async ({ page, project }) => { test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") - await withBackendProject(async (project) => { - await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => { - project.trackSession(session.id) - const prompt = page.locator(promptSelector) + await project.open() + await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => { + project.trackSession(session.id) + const prompt = page.locator(promptSelector) - await seed(project.sdk, session.id) - await project.gotoSession(session.id) + await seed(project.sdk, session.id) + await project.gotoSession(session.id) - await prompt.click() - await page.keyboard.type("/share") - await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() - await page.keyboard.press("Enter") + await prompt.click() + await page.keyboard.type("/share") + await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible() + await page.keyboard.press("Enter") - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.share?.url || undefined - }, - { timeout: 30_000 }, - ) - .not.toBeUndefined() + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() - await prompt.click() - await page.keyboard.type("/unshare") - await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() - await page.keyboard.press("Enter") + await prompt.click() + await page.keyboard.type("/unshare") + await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible() + await page.keyboard.press("Enter") - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.share?.url || undefined - }, - { timeout: 30_000 }, - ) - .toBeUndefined() - }) + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .toBeUndefined() }) }) diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts index 1ab4746e42..34a1a9e2e7 100644 --- a/packages/app/e2e/session/session-child-navigation.spec.ts +++ b/packages/app/e2e/session/session-child-navigation.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from "../fixtures" import { inputMatch } from "../prompt/mock" import { promptSelector } from "../selectors" -test("task tool child-session link does not trigger stale show errors", async ({ page, llm, withMockProject }) => { +test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => { test.setTimeout(120_000) const errs: string[] = [] @@ -13,34 +13,33 @@ test("task tool child-session link does not trigger stale show errors", async ({ page.on("pageerror", onError) try { - await withMockProject(async ({ gotoSession, trackSession, sdk }) => { - await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => { - const taskInput = { - description: "Open child session", - prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", - subagent_type: "general", - } - await llm.toolMatch(inputMatch(taskInput), "task", taskInput) - const child = await seedSessionTask(sdk, { - sessionID: session.id, - description: taskInput.description, - prompt: taskInput.prompt, - }) - trackSession(child.sessionID) - - await gotoSession(session.id) - - const link = page - .locator("a.subagent-link") - .filter({ hasText: /open child session/i }) - .first() - await expect(link).toBeVisible({ timeout: 30_000 }) - await link.click() - - await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) - await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 }) - await expect.poll(() => errs, { timeout: 5_000 }).toEqual([]) + await project.open() + await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => { + const taskInput = { + description: "Open child session", + prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", + subagent_type: "general", + } + await llm.toolMatch(inputMatch(taskInput), "task", taskInput) + const child = await seedSessionTask(project.sdk, { + sessionID: session.id, + description: taskInput.description, + prompt: taskInput.prompt, }) + project.trackSession(child.sessionID) + + await project.gotoSession(session.id) + + const link = page + .locator("a.subagent-link") + .filter({ hasText: /open child session/i }) + .first() + await expect(link).toBeVisible({ timeout: 30_000 }) + await link.click() + + await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) + await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 }) + await expect.poll(() => errs, { timeout: 5_000 }).toEqual([]) }) } finally { page.off("pageerror", onError) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index bf0cc35b71..e997f29f84 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -269,240 +269,227 @@ async function withMockPermission( } } -test("default dock shows prompt input", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock default", - async (session) => { +test("default dock shows prompt input", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock default", + async (session) => { + await project.gotoSession(session.id) + + await expect(page.locator(sessionComposerDockSelector)).toBeVisible() + await expect(page.locator(promptSelector)).toBeVisible() + await expect(page.locator(questionDockSelector)).toHaveCount(0) + await expect(page.locator(permissionDockSelector)).toHaveCount(0) + + await page.locator(promptSelector).click() + await expect(page.locator(promptSelector)).toBeFocused() + }, + { trackSession: project.trackSession }, + ) +}) + +test("auto-accept toggle works before first submit", async ({ page, project }) => { + await project.open() + + const button = page.locator('[data-action="prompt-permissions"]').first() + await expect(button).toBeVisible() + await expect(button).toHaveAttribute("aria-pressed", "false") + + await setAutoAccept(page, true) + await setAutoAccept(page, false) +}) + +test("blocked question flow unblocks after submit", async ({ page, llm, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock question", + async (session) => { + await withDockSeed(project.sdk, session.id, async () => { await project.gotoSession(session.id) - await expect(page.locator(sessionComposerDockSelector)).toBeVisible() - await expect(page.locator(promptSelector)).toBeVisible() - await expect(page.locator(questionDockSelector)).toHaveCount(0) - await expect(page.locator(permissionDockSelector)).toHaveCount(0) - - await page.locator(promptSelector).click() - await expect(page.locator(promptSelector)).toBeFocused() - }, - { trackSession: project.trackSession }, - ) - }) -}) - -test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => { - await withBackendProject(async ({ gotoSession }) => { - await gotoSession() - - const button = page.locator('[data-action="prompt-permissions"]').first() - await expect(button).toBeVisible() - await expect(button).toHaveAttribute("aria-pressed", "false") - - await setAutoAccept(page, true) - await setAutoAccept(page, false) - }) -}) - -test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => { - await withMockProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock question", - async (session) => { - await withDockSeed(project.sdk, session.id, async () => { - await project.gotoSession(session.id) - - await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions: defaultQuestions, - }) - - const dock = page.locator(questionDockSelector) - await expectQuestionBlocked(page) - - await dock.locator('[data-slot="question-option"]').first().click() - await dock.getByRole("button", { name: /submit/i }).click() - - await expectQuestionOpen(page) + await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) + await seedSessionQuestion(project.sdk, { + sessionID: session.id, + questions: defaultQuestions, }) - }, - { trackSession: project.trackSession }, - ) - }) + + const dock = page.locator(questionDockSelector) + await expectQuestionBlocked(page) + + await dock.locator('[data-slot="question-option"]').first().click() + await dock.getByRole("button", { name: /submit/i }).click() + + await expectQuestionOpen(page) + }) + }, + { trackSession: project.trackSession }, + ) }) -test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => { - await withMockProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock question keyboard", - async (session) => { - await withDockSeed(project.sdk, session.id, async () => { - await project.gotoSession(session.id) +test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock question keyboard", + async (session) => { + await withDockSeed(project.sdk, session.id, async () => { + await project.gotoSession(session.id) - await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions: defaultQuestions, - }) - - const dock = page.locator(questionDockSelector) - const first = dock.locator('[data-slot="question-option"]').first() - const second = dock.locator('[data-slot="question-option"]').nth(1) - - await expectQuestionBlocked(page) - await expect(first).toBeFocused() - - await page.keyboard.press("ArrowDown") - await expect(second).toBeFocused() - - await page.keyboard.press("Space") - await page.keyboard.press(`${modKey}+Enter`) - await expectQuestionOpen(page) + await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) + await seedSessionQuestion(project.sdk, { + sessionID: session.id, + questions: defaultQuestions, }) - }, - { trackSession: project.trackSession }, - ) - }) + + const dock = page.locator(questionDockSelector) + const first = dock.locator('[data-slot="question-option"]').first() + const second = dock.locator('[data-slot="question-option"]').nth(1) + + await expectQuestionBlocked(page) + await expect(first).toBeFocused() + + await page.keyboard.press("ArrowDown") + await expect(second).toBeFocused() + + await page.keyboard.press("Space") + await page.keyboard.press(`${modKey}+Enter`) + await expectQuestionOpen(page) + }) + }, + { trackSession: project.trackSession }, + ) }) -test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => { - await withMockProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock question escape", - async (session) => { - await withDockSeed(project.sdk, session.id, async () => { - await project.gotoSession(session.id) +test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock question escape", + async (session) => { + await withDockSeed(project.sdk, session.id, async () => { + await project.gotoSession(session.id) - await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions: defaultQuestions, - }) - - const dock = page.locator(questionDockSelector) - const first = dock.locator('[data-slot="question-option"]').first() - - await expectQuestionBlocked(page) - await expect(first).toBeFocused() - - await page.keyboard.press("Escape") - await expectQuestionOpen(page) + await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions }) + await seedSessionQuestion(project.sdk, { + sessionID: session.id, + questions: defaultQuestions, }) - }, - { trackSession: project.trackSession }, - ) - }) + + const dock = page.locator(questionDockSelector) + const first = dock.locator('[data-slot="question-option"]').first() + + await expectQuestionBlocked(page) + await expect(first).toBeFocused() + + await page.keyboard.press("Escape") + await expectQuestionOpen(page) + }) + }, + { trackSession: project.trackSession }, + ) }) -test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock permission once", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) - await withMockPermission( - page, - { - id: "per_e2e_once", - sessionID: session.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-once"], - metadata: { description: "Need permission for command" }, - }, - undefined, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) +test("blocked permission flow supports allow once", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock permission once", + async (session) => { + await project.gotoSession(session.id) + await setAutoAccept(page, false) + await withMockPermission( + page, + { + id: "per_e2e_once", + sessionID: session.id, + permission: "bash", + patterns: ["/tmp/opencode-e2e-perm-once"], + metadata: { description: "Need permission for command" }, + }, + undefined, + async (state) => { + await page.goto(page.url()) + await expectPermissionBlocked(page) - await clearPermissionDock(page, /allow once/i) - await state.resolved() - await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - }, - { trackSession: project.trackSession }, - ) - }) + await clearPermissionDock(page, /allow once/i) + await state.resolved() + await page.goto(page.url()) + await expectPermissionOpen(page) + }, + ) + }, + { trackSession: project.trackSession }, + ) }) -test("blocked permission flow supports reject", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock permission reject", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) - await withMockPermission( - page, - { - id: "per_e2e_reject", - sessionID: session.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-reject"], - }, - undefined, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) +test("blocked permission flow supports reject", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock permission reject", + async (session) => { + await project.gotoSession(session.id) + await setAutoAccept(page, false) + await withMockPermission( + page, + { + id: "per_e2e_reject", + sessionID: session.id, + permission: "bash", + patterns: ["/tmp/opencode-e2e-perm-reject"], + }, + undefined, + async (state) => { + await page.goto(page.url()) + await expectPermissionBlocked(page) - await clearPermissionDock(page, /deny/i) - await state.resolved() - await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - }, - { trackSession: project.trackSession }, - ) - }) + await clearPermissionDock(page, /deny/i) + await state.resolved() + await page.goto(page.url()) + await expectPermissionOpen(page) + }, + ) + }, + { trackSession: project.trackSession }, + ) }) -test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock permission always", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) - await withMockPermission( - page, - { - id: "per_e2e_always", - sessionID: session.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-always"], - metadata: { description: "Need permission for command" }, - }, - undefined, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) +test("blocked permission flow supports allow always", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock permission always", + async (session) => { + await project.gotoSession(session.id) + await setAutoAccept(page, false) + await withMockPermission( + page, + { + id: "per_e2e_always", + sessionID: session.id, + permission: "bash", + patterns: ["/tmp/opencode-e2e-perm-always"], + metadata: { description: "Need permission for command" }, + }, + undefined, + async (state) => { + await page.goto(page.url()) + await expectPermissionBlocked(page) - await clearPermissionDock(page, /allow always/i) - await state.resolved() - await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - }, - { trackSession: project.trackSession }, - ) - }) + await clearPermissionDock(page, /allow always/i) + await state.resolved() + await page.goto(page.url()) + await expectPermissionOpen(page) + }, + ) + }, + { trackSession: project.trackSession }, + ) }) -test("child session question request blocks parent dock and unblocks after submit", async ({ - page, - llm, - withMockProject, -}) => { +test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => { const questions = [ { header: "Child input", @@ -513,137 +500,131 @@ test("child session question request blocks parent dock and unblocks after submi ], }, ] - await withMockProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock child question parent", - async (session) => { - await project.gotoSession(session.id) + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock child question parent", + async (session) => { + await project.gotoSession(session.id) - const child = await project.sdk.session - .create({ - title: "e2e composer dock child question", - parentID: session.id, + const child = await project.sdk.session + .create({ + title: "e2e composer dock child question", + parentID: session.id, + }) + .then((r) => r.data) + if (!child?.id) throw new Error("Child session create did not return an id") + project.trackSession(child.id) + + try { + await withDockSeed(project.sdk, child.id, async () => { + await llm.toolMatch(inputMatch({ questions }), "question", { questions }) + await seedSessionQuestion(project.sdk, { + sessionID: child.id, + questions, }) - .then((r) => r.data) - if (!child?.id) throw new Error("Child session create did not return an id") - project.trackSession(child.id) - try { - await withDockSeed(project.sdk, child.id, async () => { - await llm.toolMatch(inputMatch({ questions }), "question", { questions }) - await seedSessionQuestion(project.sdk, { - sessionID: child.id, - questions, - }) + const dock = page.locator(questionDockSelector) + await expectQuestionBlocked(page) - const dock = page.locator(questionDockSelector) - await expectQuestionBlocked(page) + await dock.locator('[data-slot="question-option"]').first().click() + await dock.getByRole("button", { name: /submit/i }).click() - await dock.locator('[data-slot="question-option"]').first().click() - await dock.getByRole("button", { name: /submit/i }).click() - - await expectQuestionOpen(page) - }) - } finally { - await cleanupSession({ sdk: project.sdk, sessionID: child.id }) - } - }, - { trackSession: project.trackSession }, - ) - }) + await expectQuestionOpen(page) + }) + } finally { + await cleanupSession({ sdk: project.sdk, sessionID: child.id }) + } + }, + { trackSession: project.trackSession }, + ) }) -test("child session permission request blocks parent dock and supports allow once", async ({ - page, - withBackendProject, -}) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock child permission parent", - async (session) => { - await project.gotoSession(session.id) - await setAutoAccept(page, false) +test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock child permission parent", + async (session) => { + await project.gotoSession(session.id) + await setAutoAccept(page, false) - const child = await project.sdk.session - .create({ - title: "e2e composer dock child permission", - parentID: session.id, - }) - .then((r) => r.data) - if (!child?.id) throw new Error("Child session create did not return an id") - project.trackSession(child.id) + const child = await project.sdk.session + .create({ + title: "e2e composer dock child permission", + parentID: session.id, + }) + .then((r) => r.data) + if (!child?.id) throw new Error("Child session create did not return an id") + project.trackSession(child.id) - try { - await withMockPermission( - page, - { - id: "per_e2e_child", - sessionID: child.id, - permission: "bash", - patterns: ["/tmp/opencode-e2e-perm-child"], - metadata: { description: "Need child permission" }, - }, - { child }, - async (state) => { - await page.goto(page.url()) - await expectPermissionBlocked(page) + try { + await withMockPermission( + page, + { + id: "per_e2e_child", + sessionID: child.id, + permission: "bash", + patterns: ["/tmp/opencode-e2e-perm-child"], + metadata: { description: "Need child permission" }, + }, + { child }, + async (state) => { + await page.goto(page.url()) + await expectPermissionBlocked(page) - await clearPermissionDock(page, /allow once/i) - await state.resolved() - await page.goto(page.url()) + await clearPermissionDock(page, /allow once/i) + await state.resolved() + await page.goto(page.url()) - await expectPermissionOpen(page) - }, - ) - } finally { - await cleanupSession({ sdk: project.sdk, sessionID: child.id }) - } - }, - { trackSession: project.trackSession }, - ) - }) + await expectPermissionOpen(page) + }, + ) + } finally { + await cleanupSession({ sdk: project.sdk, sessionID: child.id }) + } + }, + { trackSession: project.trackSession }, + ) }) -test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => { - await withBackendProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock todo", - async (session) => { - const dock = await todoDock(page, session.id) - await project.gotoSession(session.id) - await expect(page.locator(sessionComposerDockSelector)).toBeVisible() +test("todo dock transitions and collapse behavior", async ({ page, project }) => { + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock todo", + async (session) => { + const dock = await todoDock(page, session.id) + await project.gotoSession(session.id) + await expect(page.locator(sessionComposerDockSelector)).toBeVisible() - try { - await dock.open([ - { content: "first task", status: "pending", priority: "high" }, - { content: "second task", status: "in_progress", priority: "medium" }, - ]) - await dock.expectOpen(["pending", "in_progress"]) + try { + await dock.open([ + { content: "first task", status: "pending", priority: "high" }, + { content: "second task", status: "in_progress", priority: "medium" }, + ]) + await dock.expectOpen(["pending", "in_progress"]) - await dock.collapse() - await dock.expectCollapsed(["pending", "in_progress"]) + await dock.collapse() + await dock.expectCollapsed(["pending", "in_progress"]) - await dock.expand() - await dock.expectOpen(["pending", "in_progress"]) + await dock.expand() + await dock.expectOpen(["pending", "in_progress"]) - await dock.finish([ - { content: "first task", status: "completed", priority: "high" }, - { content: "second task", status: "cancelled", priority: "medium" }, - ]) - await dock.expectClosed() - } finally { - await dock.clear() - } - }, - { trackSession: project.trackSession }, - ) - }) + await dock.finish([ + { content: "first task", status: "completed", priority: "high" }, + { content: "second task", status: "cancelled", priority: "medium" }, + ]) + await dock.expectClosed() + } finally { + await dock.clear() + } + }, + { trackSession: project.trackSession }, + ) }) -test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMockProject }) => { +test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => { const questions = [ { header: "Need input", @@ -651,28 +632,27 @@ test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMo options: [{ label: "Continue", description: "Continue now" }], }, ] - await withMockProject(async (project) => { - await withDockSession( - project.sdk, - "e2e composer dock keyboard", - async (session) => { - await withDockSeed(project.sdk, session.id, async () => { - await project.gotoSession(session.id) + await project.open() + await withDockSession( + project.sdk, + "e2e composer dock keyboard", + async (session) => { + await withDockSeed(project.sdk, session.id, async () => { + await project.gotoSession(session.id) - await llm.toolMatch(inputMatch({ questions }), "question", { questions }) - await seedSessionQuestion(project.sdk, { - sessionID: session.id, - questions, - }) - - await expectQuestionBlocked(page) - - await page.locator("main").click({ position: { x: 5, y: 5 } }) - await page.keyboard.type("abc") - await expect(page.locator(promptSelector)).toHaveCount(0) + await llm.toolMatch(inputMatch({ questions }), "question", { questions }) + await seedSessionQuestion(project.sdk, { + sessionID: session.id, + questions, }) - }, - { trackSession: project.trackSession }, - ) - }) + + await expectQuestionBlocked(page) + + await page.locator("main").click({ position: { x: 5, y: 5 } }) + await page.keyboard.type("abc") + await expect(page.locator(promptSelector)).toHaveCount(0) + }) + }, + { trackSession: project.trackSession }, + ) }) diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts index 66bc451bcf..8801e410f2 100644 --- a/packages/app/e2e/session/session-model-persistence.spec.ts +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -1,15 +1,6 @@ import type { Locator, Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { - openSidebar, - resolveSlug, - sessionIDFromUrl, - setWorkspacesEnabled, - waitSession, - waitSessionIdle, - waitSlug, - withNoReplyPrompt, -} from "../actions" +import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions" import { promptAgentSelector, promptModelSelector, @@ -230,35 +221,8 @@ async function goto(page: Page, directory: string, sessionID?: string) { await waitSession(page, { directory, sessionID }) } -async function submit(page: Page, value: string) { - const prompt = page.locator('[data-component="prompt-input"]') - await expect(prompt).toBeVisible() - - await withNoReplyPrompt(page, async () => { - await prompt.click() - await prompt.fill(value) - await prompt.press("Enter") - }) - - await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") - const id = sessionIDFromUrl(page.url()) - if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`) - return id -} - -async function waitUser(directory: string, sessionID: string) { - const sdk = createSdk(directory) - await expect - .poll( - async () => { - const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? []) - return items.some((item) => item.info.role === "user") - }, - { timeout: 30_000 }, - ) - .toBe(true) - await sdk.session.abort({ sessionID }).catch(() => undefined) - await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined) +async function submit(project: Parameters[0]["project"], value: string) { + return project.user(value) } async function createWorkspace(page: Page, root: string, seen: string[]) { @@ -301,108 +265,98 @@ async function newWorkspaceSession(page: Page, slug: string) { return waitSession(page, { directory: next.directory }).then((item) => item.directory) } -test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => { +test("session model restore per session without leaking into new sessions", async ({ page, project }) => { await page.setViewportSize({ width: 1440, height: 900 }) - await withProject(async ({ directory, gotoSession, trackSession }) => { - await gotoSession() + await project.open() + await project.gotoSession() - const firstState = await chooseOtherModel(page) - const firstKey = await currentModel(page) - const first = await submit(page, `session variant ${Date.now()}`) - trackSession(first) - await waitUser(directory, first) + const firstState = await chooseOtherModel(page) + const firstKey = await currentModel(page) + const first = await submit(project, `session variant ${Date.now()}`) - await page.reload() - await waitSession(page, { directory, sessionID: first }) - await waitFooter(page, firstState) + await page.reload() + await waitSession(page, { directory: project.directory, sessionID: first }) + await waitFooter(page, firstState) - await gotoSession() - const fresh = await read(page) - expect(fresh.model).not.toBe(firstState.model) + await project.gotoSession() + const fresh = await read(page) + expect(fresh.model).not.toBe(firstState.model) - const secondState = await chooseOtherModel(page, [firstKey]) - const second = await submit(page, `session model ${Date.now()}`) - trackSession(second) - await waitUser(directory, second) + const secondState = await chooseOtherModel(page, [firstKey]) + const second = await submit(project, `session model ${Date.now()}`) - await goto(page, directory, first) - await waitFooter(page, firstState) + await goto(page, project.directory, first) + await waitFooter(page, firstState) - await goto(page, directory, second) - await waitFooter(page, secondState) + await goto(page, project.directory, second) + await waitFooter(page, secondState) - await gotoSession() - await waitFooter(page, fresh) - }) + await project.gotoSession() + await page.reload() + await waitSession(page, { directory: project.directory }) + await waitFooter(page, fresh) }) -test("session model restore across workspaces", async ({ page, withProject }) => { +test("session model restore across workspaces", async ({ page, project }) => { await page.setViewportSize({ width: 1440, height: 900 }) - await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => { - await gotoSession() + await project.open() + const root = project.directory + await project.gotoSession() - const firstState = await chooseOtherModel(page) - const firstKey = await currentModel(page) - const first = await submit(page, `root session ${Date.now()}`) - trackSession(first, root) - await waitUser(root, first) + const firstState = await chooseOtherModel(page) + const firstKey = await currentModel(page) + const first = await submit(project, `root session ${Date.now()}`) - await openSidebar(page) - await setWorkspacesEnabled(page, slug, true) + await openSidebar(page) + await setWorkspacesEnabled(page, project.slug, true) - const one = await createWorkspace(page, slug, []) - const oneDir = await newWorkspaceSession(page, one.slug) - trackDirectory(oneDir) + const one = await createWorkspace(page, project.slug, []) + const oneDir = await newWorkspaceSession(page, one.slug) + project.trackDirectory(oneDir) - const secondState = await chooseOtherModel(page, [firstKey]) - const secondKey = await currentModel(page) - const second = await submit(page, `workspace one ${Date.now()}`) - trackSession(second, oneDir) - await waitUser(oneDir, second) + const secondState = await chooseOtherModel(page, [firstKey]) + const secondKey = await currentModel(page) + const second = await submit(project, `workspace one ${Date.now()}`) - const two = await createWorkspace(page, slug, [one.slug]) - const twoDir = await newWorkspaceSession(page, two.slug) - trackDirectory(twoDir) + const two = await createWorkspace(page, project.slug, [one.slug]) + const twoDir = await newWorkspaceSession(page, two.slug) + project.trackDirectory(twoDir) - const thirdState = await chooseOtherModel(page, [firstKey, secondKey]) - const third = await submit(page, `workspace two ${Date.now()}`) - trackSession(third, twoDir) - await waitUser(twoDir, third) + const thirdState = await chooseOtherModel(page, [firstKey, secondKey]) + const third = await submit(project, `workspace two ${Date.now()}`) - await goto(page, root, first) - await waitFooter(page, firstState) + await goto(page, root, first) + await waitFooter(page, firstState) - await goto(page, oneDir, second) - await waitFooter(page, secondState) + await goto(page, oneDir, second) + await waitFooter(page, secondState) - await goto(page, twoDir, third) - await waitFooter(page, thirdState) + await goto(page, twoDir, third) + await waitFooter(page, thirdState) - await goto(page, root, first) - await waitFooter(page, firstState) - }) + await goto(page, root, first) + await waitFooter(page, firstState) }) -test("variant preserved when switching agent modes", async ({ page, withProject }) => { +test("variant preserved when switching agent modes", async ({ page, project }) => { await page.setViewportSize({ width: 1440, height: 900 }) - await withProject(async ({ directory, gotoSession }) => { - await gotoSession() + await project.open() + await project.gotoSession() - await ensureVariant(page, directory) - const updated = await chooseDifferentVariant(page) + await ensureVariant(page, project.directory) + const updated = await chooseDifferentVariant(page) - const available = await agents(page) - const other = available.find((name) => name !== updated.agent) - test.skip(!other, "only one agent available") - if (!other) return + const available = await agents(page) + const other = available.find((name) => name !== updated.agent) + test.skip(!other, "only one agent available") + if (!other) return - await choose(page, promptAgentSelector, other) - await waitFooter(page, { agent: other, variant: updated.variant }) + await choose(page, promptAgentSelector, other) + await waitFooter(page, { agent: other, variant: updated.variant }) - await choose(page, promptAgentSelector, updated.agent) - await waitFooter(page, { agent: updated.agent, variant: updated.variant }) - }) + await choose(page, promptAgentSelector, updated.agent) + await waitFooter(page, { agent: updated.agent, variant: updated.variant }) }) diff --git a/packages/app/e2e/session/session-review.spec.ts b/packages/app/e2e/session/session-review.spec.ts index c7529112ff..b7695cc0b5 100644 --- a/packages/app/e2e/session/session-review.spec.ts +++ b/packages/app/e2e/session/session-review.spec.ts @@ -245,7 +245,7 @@ async function fileOverflow(page: Parameters[0]["page"]) { } } -test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, withMockProject }) => { +test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => { test.setTimeout(180_000) const tag = `review-comment-${Date.now()}` @@ -254,46 +254,45 @@ test("review applies inline comment clicks without horizontal overflow", async ( await page.setViewportSize({ width: 1280, height: 900 }) - await withMockProject(async (project) => { - await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => { - project.trackSession(session.id) - await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) + await project.open() + await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => { + project.trackSession(session.id) + await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) - await expect - .poll( - async () => { - const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) - return diff.length - }, - { timeout: 60_000 }, - ) - .toBe(1) + await expect + .poll( + async () => { + const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + return diff.length + }, + { timeout: 60_000 }, + ) + .toBe(1) - await project.gotoSession(session.id) - await show(page) + await project.gotoSession(session.id) + await show(page) - const tab = page.getByRole("tab", { name: /Review/i }).first() - await expect(tab).toBeVisible() - await tab.click() + const tab = page.getByRole("tab", { name: /Review/i }).first() + await expect(tab).toBeVisible() + await tab.click() - await expand(page) - await waitMark(page, file, tag) - await comment(page, file, note) + await expand(page) + await waitMark(page, file, tag) + await comment(page, file, note) - await expect - .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - await expect - .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - await expect - .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - }) + await expect + .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) + await expect + .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) + await expect + .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) }) }) -test("review file comments submit on click without clipping actions", async ({ page, llm, withMockProject }) => { +test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => { test.setTimeout(180_000) const tag = `review-file-comment-${Date.now()}` @@ -302,47 +301,46 @@ test("review file comments submit on click without clipping actions", async ({ p await page.setViewportSize({ width: 1280, height: 900 }) - await withMockProject(async (project) => { - await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => { - project.trackSession(session.id) - await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) + await project.open() + await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => { + project.trackSession(session.id) + await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }])) - await expect - .poll( - async () => { - const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) - return diff.length - }, - { timeout: 60_000 }, - ) - .toBe(1) + await expect + .poll( + async () => { + const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + return diff.length + }, + { timeout: 60_000 }, + ) + .toBe(1) - await project.gotoSession(session.id) - await show(page) + await project.gotoSession(session.id) + await show(page) - const tab = page.getByRole("tab", { name: /Review/i }).first() - await expect(tab).toBeVisible() - await tab.click() + const tab = page.getByRole("tab", { name: /Review/i }).first() + await expect(tab).toBeVisible() + await tab.click() - await expand(page) - await waitMark(page, file, tag) - await openReviewFile(page, file) - await fileComment(page, note) + await expand(page) + await waitMark(page, file, tag) + await openReviewFile(page, file) + await fileComment(page, note) - await expect - .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - await expect - .poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - await expect - .poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) - .toBeLessThanOrEqual(1) - }) + await expect + .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) + await expect + .poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) + await expect + .poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 }) + .toBeLessThanOrEqual(1) }) }) -test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, withMockProject }) => { +test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => { test.setTimeout(180_000) const tag = `review-${Date.now()}` @@ -352,84 +350,83 @@ test.fixme("review keeps scroll position after a live diff update", async ({ pag await page.setViewportSize({ width: 1600, height: 1000 }) - await withMockProject(async (project) => { - await withSession(project.sdk, `e2e review ${tag}`, async (session) => { - project.trackSession(session.id) - await patchWithMock(llm, project.sdk, session.id, seed(list)) + await project.open() + await withSession(project.sdk, `e2e review ${tag}`, async (session) => { + project.trackSession(session.id) + await patchWithMock(llm, project.sdk, session.id, seed(list)) - await expect - .poll( - async () => { - const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data) - return info?.summary?.files ?? 0 - }, - { timeout: 60_000 }, - ) - .toBe(list.length) + await expect + .poll( + async () => { + const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data) + return info?.summary?.files ?? 0 + }, + { timeout: 60_000 }, + ) + .toBe(list.length) - await expect - .poll( - async () => { - const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) - return diff.length - }, - { timeout: 60_000 }, - ) - .toBe(list.length) + await expect + .poll( + async () => { + const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + return diff.length + }, + { timeout: 60_000 }, + ) + .toBe(list.length) - await project.gotoSession(session.id) - await show(page) + await project.gotoSession(session.id) + await show(page) - const tab = page.getByRole("tab", { name: /Review/i }).first() - await expect(tab).toBeVisible() - await tab.click() + const tab = page.getByRole("tab", { name: /Review/i }).first() + await expect(tab).toBeVisible() + await tab.click() - const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first() - await expect(view).toBeVisible() - const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ }) - await expect(heads).toHaveCount(list.length, { timeout: 60_000 }) + const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first() + await expect(view).toBeVisible() + const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ }) + await expect(heads).toHaveCount(list.length, { timeout: 60_000 }) - await expand(page) - await waitMark(page, hit.file, hit.mark) + await expand(page) + await waitMark(page, hit.file, hit.mark) - const row = page - .getByRole("heading", { - level: 3, - name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), - }) - .first() - await expect(row).toBeVisible() - await row.evaluate((el) => el.scrollIntoView({ block: "center" })) + const row = page + .getByRole("heading", { + level: 3, + name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + }) + .first() + await expect(row).toBeVisible() + await row.evaluate((el) => el.scrollIntoView({ block: "center" })) - await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200) - const prev = await spot(page, hit.file) - if (!prev) throw new Error(`missing review row for ${hit.file}`) + await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200) + const prev = await spot(page, hit.file) + if (!prev) throw new Error(`missing review row for ${hit.file}`) - await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next)) + await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next)) - await expect - .poll( - async () => { - const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) - const item = diff.find((item) => item.file === hit.file) - return typeof item?.after === "string" ? item.after : "" - }, - { timeout: 60_000 }, - ) - .toContain(`mark ${next}`) + await expect + .poll( + async () => { + const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) + const item = diff.find((item) => item.file === hit.file) + return typeof item?.after === "string" ? item.after : "" + }, + { timeout: 60_000 }, + ) + .toContain(`mark ${next}`) - await waitMark(page, hit.file, next) + await waitMark(page, hit.file, next) - await expect - .poll( - async () => { - const next = await spot(page, hit.file) - if (!next) return Number.POSITIVE_INFINITY - return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y)) - }, - { timeout: 60_000 }, - ) - .toBeLessThanOrEqual(32) - }) + await expect + .poll( + async () => { + const next = await spot(page, hit.file) + if (!next) return Number.POSITIVE_INFINITY + return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y)) + }, + { timeout: 60_000 }, + ) + .toBeLessThanOrEqual(32) }) }) diff --git a/packages/app/e2e/session/session-undo-redo.spec.ts b/packages/app/e2e/session/session-undo-redo.spec.ts index a63bd9e3b5..709a45b4c4 100644 --- a/packages/app/e2e/session/session-undo-redo.spec.ts +++ b/packages/app/e2e/session/session-undo-redo.spec.ts @@ -49,188 +49,185 @@ async function seedConversation(input: { return { prompt, userMessageID } } -test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => { +test("slash undo sets revert and restores prior prompt", async ({ page, project }) => { test.setTimeout(120_000) const token = `undo_${Date.now()}` - await withBackendProject(async (project) => { - const sdk = project.sdk + await project.open() + const sdk = project.sdk - await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => { - project.trackSession(session.id) - await project.gotoSession(session.id) + await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) - const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) + const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) - await seeded.prompt.click() - await page.keyboard.type("/undo") + await seeded.prompt.click() + await page.keyboard.type("/undo") - const undo = page.locator('[data-slash-id="session.undo"]').first() - await expect(undo).toBeVisible() - await page.keyboard.press("Enter") + const undo = page.locator('[data-slash-id="session.undo"]').first() + await expect(undo).toBeVisible() + await page.keyboard.press("Enter") - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBe(seeded.userMessageID) + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBe(seeded.userMessageID) - await expect(seeded.prompt).toContainText(token) - await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0) - }) + await expect(seeded.prompt).toContainText(token) + await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0) }) }) -test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => { +test("slash redo clears revert and restores latest state", async ({ page, project }) => { test.setTimeout(120_000) const token = `redo_${Date.now()}` - await withBackendProject(async (project) => { - const sdk = project.sdk + await project.open() + const sdk = project.sdk - await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => { - project.trackSession(session.id) - await project.gotoSession(session.id) + await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) - const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) + const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) - await seeded.prompt.click() - await page.keyboard.type("/undo") + await seeded.prompt.click() + await page.keyboard.type("/undo") - const undo = page.locator('[data-slash-id="session.undo"]').first() - await expect(undo).toBeVisible() - await page.keyboard.press("Enter") + const undo = page.locator('[data-slash-id="session.undo"]').first() + await expect(undo).toBeVisible() + await page.keyboard.press("Enter") - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBe(seeded.userMessageID) + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBe(seeded.userMessageID) - await seeded.prompt.click() - await page.keyboard.press(`${modKey}+A`) - await page.keyboard.press("Backspace") - await page.keyboard.type("/redo") + await seeded.prompt.click() + await page.keyboard.press(`${modKey}+A`) + await page.keyboard.press("Backspace") + await page.keyboard.type("/redo") - const redo = page.locator('[data-slash-id="session.redo"]').first() - await expect(redo).toBeVisible() - await page.keyboard.press("Enter") + const redo = page.locator('[data-slash-id="session.redo"]').first() + await expect(redo).toBeVisible() + await page.keyboard.press("Enter") - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBeUndefined() + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBeUndefined() - await expect(seeded.prompt).not.toContainText(token) - await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1) - }) + await expect(seeded.prompt).not.toContainText(token) + await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1) }) }) -test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => { +test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => { test.setTimeout(120_000) const firstToken = `undo_redo_first_${Date.now()}` const secondToken = `undo_redo_second_${Date.now()}` - await withBackendProject(async (project) => { - const sdk = project.sdk + await project.open() + const sdk = project.sdk - await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => { - project.trackSession(session.id) - await project.gotoSession(session.id) + await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) - const first = await seedConversation({ - page, - sdk, - sessionID: session.id, - token: firstToken, - }) - const second = await seedConversation({ - page, - sdk, - sessionID: session.id, - token: secondToken, - }) - - expect(first.userMessageID).not.toBe(second.userMessageID) - - const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`) - const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`) - - await expect(firstMessage).toHaveCount(1) - await expect(secondMessage).toHaveCount(1) - - await second.prompt.click() - await page.keyboard.press(`${modKey}+A`) - await page.keyboard.press("Backspace") - await page.keyboard.type("/undo") - - const undo = page.locator('[data-slash-id="session.undo"]').first() - await expect(undo).toBeVisible() - await page.keyboard.press("Enter") - - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBe(second.userMessageID) - - await expect(firstMessage).toHaveCount(1) - await expect(secondMessage).toHaveCount(0) - - await second.prompt.click() - await page.keyboard.press(`${modKey}+A`) - await page.keyboard.press("Backspace") - await page.keyboard.type("/undo") - await expect(undo).toBeVisible() - await page.keyboard.press("Enter") - - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBe(first.userMessageID) - - await expect(firstMessage).toHaveCount(0) - await expect(secondMessage).toHaveCount(0) - - await second.prompt.click() - await page.keyboard.press(`${modKey}+A`) - await page.keyboard.press("Backspace") - await page.keyboard.type("/redo") - - const redo = page.locator('[data-slash-id="session.redo"]').first() - await expect(redo).toBeVisible() - await page.keyboard.press("Enter") - - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBe(second.userMessageID) - - await expect(firstMessage).toHaveCount(1) - await expect(secondMessage).toHaveCount(0) - - await second.prompt.click() - await page.keyboard.press(`${modKey}+A`) - await page.keyboard.press("Backspace") - await page.keyboard.type("/redo") - await expect(redo).toBeVisible() - await page.keyboard.press("Enter") - - await expect - .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { - timeout: 30_000, - }) - .toBeUndefined() - - await expect(firstMessage).toHaveCount(1) - await expect(secondMessage).toHaveCount(1) + const first = await seedConversation({ + page, + sdk, + sessionID: session.id, + token: firstToken, }) + const second = await seedConversation({ + page, + sdk, + sessionID: session.id, + token: secondToken, + }) + + expect(first.userMessageID).not.toBe(second.userMessageID) + + const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`) + const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`) + + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(1) + + await second.prompt.click() + await page.keyboard.press(`${modKey}+A`) + await page.keyboard.press("Backspace") + await page.keyboard.type("/undo") + + const undo = page.locator('[data-slash-id="session.undo"]').first() + await expect(undo).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBe(second.userMessageID) + + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(0) + + await second.prompt.click() + await page.keyboard.press(`${modKey}+A`) + await page.keyboard.press("Backspace") + await page.keyboard.type("/undo") + await expect(undo).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBe(first.userMessageID) + + await expect(firstMessage).toHaveCount(0) + await expect(secondMessage).toHaveCount(0) + + await second.prompt.click() + await page.keyboard.press(`${modKey}+A`) + await page.keyboard.press("Backspace") + await page.keyboard.type("/redo") + + const redo = page.locator('[data-slash-id="session.redo"]').first() + await expect(redo).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBe(second.userMessageID) + + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(0) + + await second.prompt.click() + await page.keyboard.press(`${modKey}+A`) + await page.keyboard.press("Backspace") + await page.keyboard.type("/redo") + await expect(redo).toBeVisible() + await page.keyboard.press("Enter") + + await expect + .poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), { + timeout: 30_000, + }) + .toBeUndefined() + + await expect(firstMessage).toHaveCount(1) + await expect(secondMessage).toHaveCount(1) }) }) diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 6c885460c4..1b5fb1b600 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -31,156 +31,152 @@ async function seedMessage(sdk: Sdk, sessionID: string) { .toBeGreaterThan(0) } -test("session can be renamed via header menu", async ({ page, withBackendProject }) => { +test("session can be renamed via header menu", async ({ page, project }) => { const stamp = Date.now() const originalTitle = `e2e rename test ${stamp}` const renamedTitle = `e2e renamed ${stamp}` - await withBackendProject(async (project) => { - await withSession(project.sdk, originalTitle, async (session) => { - project.trackSession(session.id) - await seedMessage(project.sdk, session.id) - await project.gotoSession(session.id) - await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) + await project.open() + await withSession(project.sdk, originalTitle, async (session) => { + project.trackSession(session.id) + await seedMessage(project.sdk, session.id) + await project.gotoSession(session.id) + await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) - const menu = await openSessionMoreMenu(page, session.id) - await clickMenuItem(menu, /rename/i) + const menu = await openSessionMoreMenu(page, session.id) + await clickMenuItem(menu, /rename/i) - const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() - await expect(input).toBeVisible() - await expect(input).toBeFocused() - await input.fill(renamedTitle) - await expect(input).toHaveValue(renamedTitle) - await input.press("Enter") + const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() + await expect(input).toBeVisible() + await expect(input).toBeFocused() + await input.fill(renamedTitle) + await expect(input).toHaveValue(renamedTitle) + await input.press("Enter") - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.title - }, - { timeout: 30_000 }, - ) - .toBe(renamedTitle) + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.title + }, + { timeout: 30_000 }, + ) + .toBe(renamedTitle) - await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) - }) + await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) }) }) -test("session can be archived via header menu", async ({ page, withBackendProject }) => { +test("session can be archived via header menu", async ({ page, project }) => { const stamp = Date.now() const title = `e2e archive test ${stamp}` - await withBackendProject(async (project) => { - await withSession(project.sdk, title, async (session) => { - project.trackSession(session.id) - await seedMessage(project.sdk, session.id) - await project.gotoSession(session.id) - const menu = await openSessionMoreMenu(page, session.id) - await clickMenuItem(menu, /archive/i) + await project.open() + await withSession(project.sdk, title, async (session) => { + project.trackSession(session.id) + await seedMessage(project.sdk, session.id) + await project.gotoSession(session.id) + const menu = await openSessionMoreMenu(page, session.id) + await clickMenuItem(menu, /archive/i) - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.time?.archived - }, - { timeout: 30_000 }, - ) - .not.toBeUndefined() + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.time?.archived + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() - await openSidebar(page) - await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) - }) + await openSidebar(page) + await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) }) }) -test("session can be deleted via header menu", async ({ page, withBackendProject }) => { +test("session can be deleted via header menu", async ({ page, project }) => { const stamp = Date.now() const title = `e2e delete test ${stamp}` - await withBackendProject(async (project) => { - await withSession(project.sdk, title, async (session) => { - project.trackSession(session.id) - await seedMessage(project.sdk, session.id) - await project.gotoSession(session.id) - const menu = await openSessionMoreMenu(page, session.id) - await clickMenuItem(menu, /delete/i) - await confirmDialog(page, /delete/i) + await project.open() + await withSession(project.sdk, title, async (session) => { + project.trackSession(session.id) + await seedMessage(project.sdk, session.id) + await project.gotoSession(session.id) + const menu = await openSessionMoreMenu(page, session.id) + await clickMenuItem(menu, /delete/i) + await confirmDialog(page, /delete/i) - await expect - .poll( - async () => { - const data = await project.sdk.session - .get({ sessionID: session.id }) - .then((r) => r.data) - .catch(() => undefined) - return data?.id - }, - { timeout: 30_000 }, - ) - .toBeUndefined() + await expect + .poll( + async () => { + const data = await project.sdk.session + .get({ sessionID: session.id }) + .then((r) => r.data) + .catch(() => undefined) + return data?.id + }, + { timeout: 30_000 }, + ) + .toBeUndefined() - await openSidebar(page) - await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) - }) + await openSidebar(page) + await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) }) }) -test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => { +test("session can be shared and unshared via header button", async ({ page, project }) => { test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") const stamp = Date.now() const title = `e2e share test ${stamp}` - await withBackendProject(async (project) => { - await withSession(project.sdk, title, async (session) => { - project.trackSession(session.id) - await seedMessage(project.sdk, session.id) - await project.gotoSession(session.id) + await project.open() + await withSession(project.sdk, title, async (session) => { + project.trackSession(session.id) + await project.gotoSession(session.id) + await project.prompt(`share seed ${stamp}`) - const shared = await openSharePopover(page) - const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() - await expect(publish).toBeVisible({ timeout: 30_000 }) - await publish.click() + const shared = await openSharePopover(page) + const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() + await expect(publish).toBeVisible({ timeout: 30_000 }) + await publish.click() - await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({ - timeout: 30_000, - }) + await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({ + timeout: 30_000, + }) - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.share?.url || undefined - }, - { timeout: 30_000 }, - ) - .not.toBeUndefined() + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .not.toBeUndefined() - const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first() - await expect(unpublish).toBeVisible({ timeout: 30_000 }) - await unpublish.click() + const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first() + await expect(unpublish).toBeVisible({ timeout: 30_000 }) + await unpublish.click() - await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ - timeout: 30_000, - }) + await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ + timeout: 30_000, + }) - await expect - .poll( - async () => { - const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) - return data?.share?.url || undefined - }, - { timeout: 30_000 }, - ) - .toBeUndefined() + await expect + .poll( + async () => { + const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.share?.url || undefined + }, + { timeout: 30_000 }, + ) + .toBeUndefined() - const unshared = await openSharePopover(page) - await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ - timeout: 30_000, - }) + const unshared = await openSharePopover(page) + await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ + timeout: 30_000, }) }) }) diff --git a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts index 1317d2bb68..8f7646c3e7 100644 --- a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts @@ -48,70 +48,62 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p } }) -test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => { +test("open sidebar project popover stays closed after clicking avatar", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const slug = dirSlug(other) try { - await withProject( - async () => { - await openSidebar(page) + await project.open({ extra: [other] }) + await openSidebar(page) - const project = page.locator(projectSwitchSelector(slug)).first() - const card = page.locator('[data-component="hover-card-content"]') + const projectButton = page.locator(projectSwitchSelector(slug)).first() + const card = page.locator('[data-component="hover-card-content"]') - await expect(project).toBeVisible() - await project.hover() - await expect(card.getByText(/recent sessions/i)).toBeVisible() + await expect(projectButton).toBeVisible() + await projectButton.hover() + await expect(card.getByText(/recent sessions/i)).toBeVisible() - await page.mouse.down() - await expect(card).toHaveCount(0) - await page.mouse.up() + await page.mouse.down() + await expect(card).toHaveCount(0) + await page.mouse.up() - await waitSession(page, { directory: other }) - await expect(card).toHaveCount(0) - }, - { extra: [other] }, - ) + await waitSession(page, { directory: other }) + await expect(card).toHaveCount(0) } finally { await cleanupTestProject(other) } }) -test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => { +test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const slug = dirSlug(other) try { - await withProject( - async () => { - await openSidebar(page) - await defocus(page) + await project.open({ extra: [other] }) + await openSidebar(page) + await defocus(page) - const project = page.locator(projectSwitchSelector(slug)).first() + const projectButton = page.locator(projectSwitchSelector(slug)).first() - await expect(project).toBeVisible() + await expect(projectButton).toBeVisible() - let hit = false - for (let i = 0; i < 20; i++) { - hit = await project.evaluate((el) => { - return el.matches(":focus") || !!el.parentElement?.matches(":focus") - }) - if (hit) break - await page.keyboard.press("Tab") - } + let hit = false + for (let i = 0; i < 20; i++) { + hit = await projectButton.evaluate((el) => { + return el.matches(":focus") || !!el.parentElement?.matches(":focus") + }) + if (hit) break + await page.keyboard.press("Tab") + } - expect(hit).toBe(true) + expect(hit).toBe(true) - await page.keyboard.press("Enter") - await waitSession(page, { directory: other }) - }, - { extra: [other] }, - ) + await page.keyboard.press("Enter") + await waitSession(page, { directory: other }) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/terminal/terminal-reconnect.spec.ts b/packages/app/e2e/terminal/terminal-reconnect.spec.ts index b03ed89568..1a11a047a4 100644 --- a/packages/app/e2e/terminal/terminal-reconnect.spec.ts +++ b/packages/app/e2e/terminal/terminal-reconnect.spec.ts @@ -12,35 +12,34 @@ async function open(page: Page) { return term } -test("terminal reconnects without replacing the pty", async ({ page, withProject }) => { - await withProject(async ({ gotoSession }) => { - const name = `OPENCODE_E2E_RECONNECT_${Date.now()}` - const token = `E2E_RECONNECT_${Date.now()}` +test("terminal reconnects without replacing the pty", async ({ page, project }) => { + await project.open() + const name = `OPENCODE_E2E_RECONNECT_${Date.now()}` + const token = `E2E_RECONNECT_${Date.now()}` - await gotoSession() + await project.gotoSession() - const term = await open(page) - const id = await term.getAttribute("data-pty-id") - if (!id) throw new Error("Active terminal missing data-pty-id") + const term = await open(page) + const id = await term.getAttribute("data-pty-id") + if (!id) throw new Error("Active terminal missing data-pty-id") - const prev = await terminalConnects(page, { term }) + const prev = await terminalConnects(page, { term }) - await runTerminal(page, { - term, - cmd: `export ${name}=${token}; echo ${token}`, - token, - }) + await runTerminal(page, { + term, + cmd: `export ${name}=${token}; echo ${token}`, + token, + }) - await disconnectTerminal(page, { term }) + await disconnectTerminal(page, { term }) - await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev) - await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id) + await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev) + await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id) - await runTerminal(page, { - term, - cmd: `echo $${name}`, - token, - timeout: 15_000, - }) + await runTerminal(page, { + term, + cmd: `echo $${name}`, + token, + timeout: 15_000, }) }) diff --git a/packages/app/e2e/terminal/terminal-tabs.spec.ts b/packages/app/e2e/terminal/terminal-tabs.spec.ts index 6b6fa4c62b..5cb5bbf202 100644 --- a/packages/app/e2e/terminal/terminal-tabs.spec.ts +++ b/packages/app/e2e/terminal/terminal-tabs.spec.ts @@ -36,133 +36,130 @@ async function store(page: Page, key: string) { }, key) } -test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => { - await withProject(async ({ directory, gotoSession }) => { - const key = workspacePersistKey(directory, "terminal") - const one = `E2E_TERM_ONE_${Date.now()}` - const two = `E2E_TERM_TWO_${Date.now()}` - const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') - const first = tabs.filter({ hasText: /Terminal 1/ }).first() - const second = tabs.filter({ hasText: /Terminal 2/ }).first() +test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => { + await project.open() + const key = workspacePersistKey(project.directory, "terminal") + const one = `E2E_TERM_ONE_${Date.now()}` + const two = `E2E_TERM_TWO_${Date.now()}` + const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') + const first = tabs.filter({ hasText: /Terminal 1/ }).first() + const second = tabs.filter({ hasText: /Terminal 2/ }).first() - await gotoSession() - await open(page) + await project.gotoSession() + await open(page) - await runTerminal(page, { cmd: `echo ${one}`, token: one }) + await runTerminal(page, { cmd: `echo ${one}`, token: one }) - await page.getByRole("button", { name: /new terminal/i }).click() - await expect(tabs).toHaveCount(2) + await page.getByRole("button", { name: /new terminal/i }).click() + await expect(tabs).toHaveCount(2) - await runTerminal(page, { cmd: `echo ${two}`, token: two }) + await runTerminal(page, { cmd: `echo ${two}`, token: two }) - await first.click() - await expect(first).toHaveAttribute("aria-selected", "true") + await first.click() + await expect(first).toHaveAttribute("aria-selected", "true") - await expect - .poll( - async () => { - const state = await store(page, key) - const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" - const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" - return { - first: first.includes(one), - second: second.includes(two), - } - }, - { timeout: 5_000 }, - ) - .toEqual({ first: false, second: true }) + await expect + .poll( + async () => { + const state = await store(page, key) + const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" + const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" + return { + first: first.includes(one), + second: second.includes(two), + } + }, + { timeout: 5_000 }, + ) + .toEqual({ first: false, second: true }) - await second.click() - await expect(second).toHaveAttribute("aria-selected", "true") - await expect - .poll( - async () => { - const state = await store(page, key) - const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" - const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" - return { - first: first.includes(one), - second: second.includes(two), - } - }, - { timeout: 5_000 }, - ) - .toEqual({ first: true, second: false }) - }) + await second.click() + await expect(second).toHaveAttribute("aria-selected", "true") + await expect + .poll( + async () => { + const state = await store(page, key) + const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" + const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" + return { + first: first.includes(one), + second: second.includes(two), + } + }, + { timeout: 5_000 }, + ) + .toEqual({ first: true, second: false }) }) -test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => { - await withProject(async ({ directory, gotoSession }) => { - const key = workspacePersistKey(directory, "terminal") - const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') +test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => { + await project.open() + const key = workspacePersistKey(project.directory, "terminal") + const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') - await gotoSession() - await open(page) + await project.gotoSession() + await open(page) - await page.getByRole("button", { name: /new terminal/i }).click() - await expect(tabs).toHaveCount(2) + await page.getByRole("button", { name: /new terminal/i }).click() + await expect(tabs).toHaveCount(2) - const second = tabs.filter({ hasText: /Terminal 2/ }).first() - await second.click() - await expect(second).toHaveAttribute("aria-selected", "true") + const second = tabs.filter({ hasText: /Terminal 2/ }).first() + await second.click() + await expect(second).toHaveAttribute("aria-selected", "true") - await second.hover() - await page - .getByRole("button", { name: /close terminal/i }) - .nth(1) - .click({ force: true }) + await second.hover() + await page + .getByRole("button", { name: /close terminal/i }) + .nth(1) + .click({ force: true }) - const first = tabs.filter({ hasText: /Terminal 1/ }).first() - await expect(tabs).toHaveCount(1) - await expect(first).toHaveAttribute("aria-selected", "true") - await expect - .poll( - async () => { - const state = await store(page, key) - return { - count: state?.all.length ?? 0, - first: state?.all.some((item) => item.titleNumber === 1) ?? false, - } - }, - { timeout: 15_000 }, - ) - .toEqual({ count: 1, first: true }) - }) + const first = tabs.filter({ hasText: /Terminal 1/ }).first() + await expect(tabs).toHaveCount(1) + await expect(first).toHaveAttribute("aria-selected", "true") + await expect + .poll( + async () => { + const state = await store(page, key) + return { + count: state?.all.length ?? 0, + first: state?.all.some((item) => item.titleNumber === 1) ?? false, + } + }, + { timeout: 15_000 }, + ) + .toEqual({ count: 1, first: true }) }) -test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => { - await withProject(async ({ directory, gotoSession }) => { - const key = workspacePersistKey(directory, "terminal") - const rename = `E2E term ${Date.now()}` - const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first() +test("terminal tab can be renamed from the context menu", async ({ page, project }) => { + await project.open() + const key = workspacePersistKey(project.directory, "terminal") + const rename = `E2E term ${Date.now()}` + const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first() - await gotoSession() - await open(page) + await project.gotoSession() + await open(page) - await expect(tab).toContainText(/Terminal 1/) - await tab.click({ button: "right" }) + await expect(tab).toContainText(/Terminal 1/) + await tab.click({ button: "right" }) - const menu = page.locator(dropdownMenuContentSelector).first() - await expect(menu).toBeVisible() - await menu.getByRole("menuitem", { name: /^Rename$/i }).click() - await expect(menu).toHaveCount(0) + const menu = page.locator(dropdownMenuContentSelector).first() + await expect(menu).toBeVisible() + await menu.getByRole("menuitem", { name: /^Rename$/i }).click() + await expect(menu).toHaveCount(0) - const input = page.locator('#terminal-panel input[type="text"]').first() - await expect(input).toBeVisible() - await input.fill(rename) - await input.press("Enter") + const input = page.locator('#terminal-panel input[type="text"]').first() + await expect(input).toBeVisible() + await input.fill(rename) + await input.press("Enter") - await expect(input).toHaveCount(0) - await expect(tab).toContainText(rename) - await expect - .poll( - async () => { - const state = await store(page, key) - return state?.all[0]?.title - }, - { timeout: 5_000 }, - ) - .toBe(rename) - }) + await expect(input).toHaveCount(0) + await expect(tab).toContainText(rename) + await expect + .poll( + async () => { + const state = await store(page, key) + return state?.all[0]?.title + }, + { timeout: 5_000 }, + ) + .toBe(rename) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index ba299fe365..06b6c1e351 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -13,6 +13,7 @@ import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { promptProbe } from "@/testing/prompt" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" import { buildRequestParts } from "./build-request-parts" @@ -307,6 +308,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.addToHistory(currentPrompt, mode) input.resetHistoryNavigation() + promptProbe.start() const projectDirectory = sdk.directory const isNewSession = !params.id @@ -426,6 +428,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } + promptProbe.submit({ sessionID: session.id, directory: sessionDirectory }) input.onSubmit?.() if (mode === "shell") { diff --git a/packages/app/src/testing/prompt.ts b/packages/app/src/testing/prompt.ts index e11462f301..5102ed825b 100644 --- a/packages/app/src/testing/prompt.ts +++ b/packages/app/src/testing/prompt.ts @@ -10,6 +10,13 @@ export type PromptProbeState = { selects: number } +export type PromptSendState = { + started: number + count: number + sessionID?: string + directory?: string +} + export const promptEnabled = () => { if (typeof window === "undefined") return false return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true @@ -53,4 +60,24 @@ export const promptProbe = { if (!state) return state.current = undefined }, + start() { + const state = root() + if (!state) return + state.sent = { + started: (state.sent?.started ?? 0) + 1, + count: state.sent?.count ?? 0, + sessionID: state.sent?.sessionID, + directory: state.sent?.directory, + } + }, + submit(input: { sessionID: string; directory: string }) { + const state = root() + if (!state) return + state.sent = { + started: state.sent?.started ?? 0, + count: (state.sent?.count ?? 0) + 1, + sessionID: input.sessionID, + directory: input.directory, + } + }, } diff --git a/packages/app/src/testing/terminal.ts b/packages/app/src/testing/terminal.ts index 2bca39b31c..db8001ddf9 100644 --- a/packages/app/src/testing/terminal.ts +++ b/packages/app/src/testing/terminal.ts @@ -23,6 +23,7 @@ export type E2EWindow = Window & { prompt?: { enabled?: boolean current?: import("./prompt").PromptProbeState + sent?: import("./prompt").PromptSendState } terminal?: { enabled?: boolean