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
160 changes: 160 additions & 0 deletions packages/app/e2e/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
import { modKey, serverUrl } from "./utils"

export async function defocus(page: Page) {
await page.mouse.click(5, 5)
}

export async function openPalette(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)

const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
return dialog
}

export async function closeDialog(page: Page, dialog: Locator) {
await page.keyboard.press("Escape")
const closed = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)

if (closed) return

await page.keyboard.press("Escape")
const closedSecond = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)

if (closedSecond) return

await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
await expect(dialog).toHaveCount(0)
}

export async function isSidebarClosed(page: Page) {
const main = page.locator("main")
const classes = (await main.getAttribute("class")) ?? ""
return classes.includes("xl:border-l")
}

export async function toggleSidebar(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+B`)
}

export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
await toggleSidebar(page)
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
}

export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return
await toggleSidebar(page)
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
}

export async function openSettings(page: Page) {
await defocus(page)

const dialog = page.getByRole("dialog")
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)

const opened = await dialog
.waitFor({ state: "visible", timeout: 3000 })
.then(() => true)
.catch(() => false)

if (opened) return dialog

await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
return dialog
}

export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
await page.addInitScript(
(args: { directory: string; serverUrl: string; extra: string[] }) => {
const key = "opencode.global.dat:server"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
try {
return JSON.parse(raw) as unknown
} catch {
return undefined
}
})()

const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
const list = Array.isArray(store.list) ? store.list : []
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
const nextProjects = { ...(projects as Record<string, unknown>) }

const add = (origin: string, directory: string) => {
const current = nextProjects[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)

if (existing.some((p) => p.worktree === directory)) return
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
}

const directories = [args.directory, ...args.extra]
for (const directory of directories) {
add("local", directory)
add(args.serverUrl, directory)
}

localStorage.setItem(
key,
JSON.stringify({
list,
projects: nextProjects,
lastProject,
}),
)
},
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
)
}

export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))

await fs.writeFile(path.join(root, "README.md"), "# e2e\n")

execSync("git init", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', {
cwd: root,
stdio: "ignore",
})

return root
}

export async function cleanupTestProject(directory: string) {
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
}

export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect } from "./fixtures"
import { serverName } from "./utils"
import { test, expect } from "../fixtures"
import { serverName } from "../utils"

test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test, expect } from "./fixtures"
import { dirPath, promptSelector } from "./utils"
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { dirPath } from "../utils"

test("project route redirects to /session", async ({ page, directory, slug }) => {
await page.goto(dirPath(directory))
Expand Down
11 changes: 11 additions & 0 deletions packages/app/e2e/app/palette.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { test, expect } from "../fixtures"
import { openPalette } from "../actions"

test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()

const dialog = await openPalette(page)

await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect } from "./fixtures"
import { serverName, serverUrl } from "./utils"
import { test, expect } from "../fixtures"
import { serverName, serverUrl } from "../utils"

const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect } from "./fixtures"
import { promptSelector } from "./utils"
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"

test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke ${Date.now()}`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test, expect } from "./fixtures"
import { modKey, promptSelector } from "./utils"
import { test, expect } from "../fixtures"
import { openSidebar } from "../actions"
import { promptSelector } from "../selectors"

test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
Expand All @@ -14,12 +15,7 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
try {
await gotoSession(one.id)

const main = page.locator("main")
const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
if (collapsed) {
await page.keyboard.press(`${modKey}+B`)
await expect(main).not.toHaveClass(/xl:border-l/)
}
await openSidebar(page)

const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { test, expect } from "./fixtures"
import { modKey } from "./utils"
import { test, expect } from "../fixtures"
import { openPalette } from "../actions"

test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
await gotoSession()

await page.keyboard.press(`${modKey}+P`)

const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const dialog = await openPalette(page)

const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect } from "./fixtures"
import { test, expect } from "../fixtures"

test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
await gotoSession()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { test, expect } from "./fixtures"
import { modKey } from "./utils"
import { test, expect } from "../fixtures"
import { openPalette } from "../actions"

test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()

const sep = process.platform === "win32" ? "\\" : "/"
const file = ["packages", "app", "package.json"].join(sep)

await page.keyboard.press(`${modKey}+P`)

const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const dialog = await openPalette(page)

const input = dialog.getByRole("textbox").first()
await input.fill(file)
Expand Down
63 changes: 14 additions & 49 deletions packages/app/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { test as base, expect } from "@playwright/test"
import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils"
import { seedProjects } from "./actions"
import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"

type TestFixtures = {
sdk: ReturnType<typeof createSdk>
Expand Down Expand Up @@ -29,54 +31,17 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await use(createSdk(directory))
},
gotoSession: async ({ page, directory }, use) => {
await page.addInitScript(
(input: { directory: string; serverUrl: string }) => {
const key = "opencode.global.dat:server"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
try {
return JSON.parse(raw) as unknown
} catch {
return undefined
}
})()

const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
const list = Array.isArray(store.list) ? store.list : []
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
const nextProjects = { ...(projects as Record<string, unknown>) }

const add = (origin: string) => {
const current = nextProjects[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)

if (existing.some((p) => p.worktree === input.directory)) return
nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing]
}

add("local")
add(input.serverUrl)

localStorage.setItem(
key,
JSON.stringify({
list,
projects: nextProjects,
lastProject,
}),
)
},
{ directory, serverUrl },
)
await seedProjects(page, { directory })
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
user: [],
variant: {},
}),
)
})

const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect } from "./fixtures"
import { promptSelector } from "./utils"
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"

test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
await gotoSession()
Expand Down
Loading