Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"packages/slack"
],
"catalog": {
"@effect/platform-node": "4.0.0-beta.35",
"@types/bun": "1.3.9",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
Expand All @@ -45,7 +44,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.35",
"effect": "4.0.0-beta.31",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
Expand Down
25 changes: 0 additions & 25 deletions packages/app/e2e/actions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises"
import os from "node:os"
Expand Down Expand Up @@ -362,30 +361,6 @@ export async function waitSlug(page: Page, skip: string[] = []) {
return next
}

export async function resolveSlug(slug: string) {
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
const resolved = await resolveDirectory(directory)
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
}

export async function waitDir(page: Page, directory: string) {
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
const slug = slugFromUrl(page.url())
if (!slug) return ""
return resolveSlug(slug)
.then((item) => item.directory)
.catch(() => "")
},
{ timeout: 45_000 },
)
.toBe(target)
return { directory: target, slug: base64Encode(target) }
}

export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
Expand Down
16 changes: 5 additions & 11 deletions packages/app/e2e/projects/projects-switch.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
sessionIDFromUrl,
waitDir,
waitSlug,
} from "../actions"
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils"

Expand Down Expand Up @@ -108,8 +100,11 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(btn).toBeVisible()
await btn.click({ force: true })

// A new workspace can be discovered via a transient slug before the route and sidebar
// settle to the canonical workspace path on Windows, so interact with either and assert
// against the resolved workspace slug.
await waitSlug(page)
await waitDir(page, space)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))

// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
Expand Down Expand Up @@ -137,7 +132,6 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(rootButton).toBeVisible()
await rootButton.click()

await waitDir(page, space)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
Expand Down
60 changes: 26 additions & 34 deletions packages/app/e2e/projects/workspace-new-session.spec.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"

function item(space: { slug: string; raw: string }) {
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
}

function button(space: { slug: string; raw: string }) {
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
}

async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page)
await expect
.poll(
async () => {
const row = page.locator(item(space)).first()
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await row.hover({ timeout: 500 })
await item.hover({ timeout: 500 })
return true
} catch {
return false
Expand All @@ -34,30 +27,29 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()

const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await waitDir(page, next.directory)
return next
const slug = await waitSlug(page, [root, ...seen])
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}

async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
await waitWorkspaceReady(page, space)
async function openWorkspaceNewSession(page: Page, slug: string) {
await waitWorkspaceReady(page, slug)

const row = page.locator(item(space)).first()
await row.hover()
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()

const next = page.locator(button(space)).first()
await expect(next).toBeVisible()
await next.click({ force: true })
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })

return waitDir(page, space.directory)
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
return next
}

async function createSessionFromWorkspace(
page: Page,
space: { slug: string; raw: string; directory: string },
text: string,
) {
const next = await openWorkspaceNewSession(page, space)
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
const next = await openWorkspaceNewSession(page, slug)

const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
Expand All @@ -68,13 +60,13 @@ async function createSessionFromWorkspace(
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")

await waitDir(page, next.directory)
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")

const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next.slug }
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next }
}

async function sessionDirectory(directory: string, sessionID: string) {
Expand All @@ -95,11 +87,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a

const first = await createWorkspace(page, root, [])
trackDirectory(first.directory)
await waitWorkspaceReady(page, first)
await waitWorkspaceReady(page, first.slug)

const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory)
await waitWorkspaceReady(page, second)
await waitWorkspaceReady(page, second.slug)

const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, first.directory)
Expand Down
30 changes: 14 additions & 16 deletions packages/app/e2e/projects/workspaces.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"

import { test, expect } from "../fixtures"
Expand All @@ -13,10 +13,8 @@ import {
confirmDialog,
openSidebar,
openWorkspaceMenu,
resolveSlug,
setWorkspacesEnabled,
slugFromUrl,
waitDir,
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
Expand All @@ -29,15 +27,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true)

await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
await waitDir(page, next.directory)
const slug = await waitSlug(page, [rootSlug])
const dir = base64Decode(slug)

await openSidebar(page)

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
Expand All @@ -49,7 +47,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
)
.toBe(true)

return { rootSlug, slug: next.slug, directory: next.directory }
return { rootSlug, slug, directory: dir }
}

test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
Expand Down Expand Up @@ -81,15 +79,15 @@ test("can create a workspace", async ({ page, withProject }) => {
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, [slug]))
await waitDir(page, next.directory)
const workspaceSlug = await waitSlug(page, [slug])
const workspaceDir = base64Decode(workspaceSlug)

await openSidebar(page)

await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(next.slug)).first()
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
try {
await item.hover({ timeout: 500 })
return true
Expand All @@ -101,9 +99,9 @@ test("can create a workspace", async ({ page, withProject }) => {
)
.toBe(true)

await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()

await cleanupTestProject(next.directory)
await cleanupTestProject(workspaceDir)
})
})

Expand All @@ -121,7 +119,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject

await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")

const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
const activeDir = base64Decode(slugFromUrl(page.url()))
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")

await openSidebar(page)
Expand Down Expand Up @@ -333,9 +331,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
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)
const slug = await waitSlug(page, [rootSlug, prev])
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })

await openSidebar(page)
}
Expand Down
18 changes: 6 additions & 12 deletions packages/app/e2e/prompt/prompt-multiline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,12 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
await expect(page).toHaveURL(/\/session\/?$/)

const prompt = page.locator(promptSelector)
await prompt.focus()
await expect(prompt).toBeFocused()

await prompt.pressSequentially("line one")
await expect(prompt).toBeFocused()

await prompt.press("Shift+Enter")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toBeFocused()

await prompt.pressSequentially("line two")
await prompt.click()
await page.keyboard.type("line one")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("line two")

await expect(page).toHaveURL(/\/session\/?$/)
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
await expect(prompt).toContainText("line one")
await expect(prompt).toContainText("line two")
})
Loading
Loading