test(app): migrate e2e to the golden project fixture
parent
2089e5e0d6
commit
7f5307ff30
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
prompt: (text: string) => Promise<string>
|
||||
user: (text: string) => Promise<string>
|
||||
shell: (cmd: string) => Promise<string>
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof test>[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()}`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof test>[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") }))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,46 +22,45 @@ async function seed(sdk: Parameters<typeof withSession>[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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -269,240 +269,227 @@ async function withMockPermission<T>(
|
|||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof test>[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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ async function fileOverflow(page: Parameters<typeof test>[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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export type E2EWindow = Window & {
|
|||
prompt?: {
|
||||
enabled?: boolean
|
||||
current?: import("./prompt").PromptProbeState
|
||||
sent?: import("./prompt").PromptSendState
|
||||
}
|
||||
terminal?: {
|
||||
enabled?: boolean
|
||||
|
|
|
|||
Loading…
Reference in New Issue