mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-03 16:37:22 +00:00
214 lines
8.6 KiB
TypeScript
214 lines
8.6 KiB
TypeScript
import { expect, type Page } from "@playwright/test"
|
|
import type { Schema } from "effect"
|
|
import {
|
|
NO_PROVIDER,
|
|
REMOTE_SERVER,
|
|
SERVER,
|
|
draftHref,
|
|
project,
|
|
provider,
|
|
seed,
|
|
session,
|
|
sessionHref,
|
|
type SeedInput,
|
|
type TabSeed,
|
|
} from "./app"
|
|
import { mockOpenCodeServer, type MockServerConfig } from "./mock-server"
|
|
import { APP_READY_TIMEOUT, expectSessionTitle } from "./waits"
|
|
|
|
export type WorkspaceInput = Partial<Omit<MockServerConfig, "directory" | "project" | "sessions" | "server">> & {
|
|
// Names the directory `C:/OpenCode/<name>`, project `proj_<slug>`, and default session `ses_<slug>`.
|
|
name: string
|
|
directory?: string
|
|
// Merged over the default project.
|
|
project?: Partial<Parameters<typeof project>[0]>
|
|
// Defaults to one session titled `name`. Omitted fields come from `session()`.
|
|
sessions?: (Partial<Parameters<typeof session>[0]> & { id: string })[]
|
|
// Merged over the default seed: the project used before, and a tab for every session.
|
|
seed?: SeedInput
|
|
}
|
|
|
|
// One project with sessions on the default mock server, plus the storage a returning user would have.
|
|
export async function mockWorkspace(page: Page, input: WorkspaceInput) {
|
|
const slug = input.name.toLowerCase().replace(/[^a-z0-9]+/g, "_")
|
|
const directory = input.directory ?? `C:/OpenCode/${input.name}`
|
|
const projectID = input.project?.id ?? `proj_${slug}`
|
|
|
|
const sessions = (input.sessions ?? [{ id: `ses_${slug}`, title: input.name }]).map((item) =>
|
|
session({ directory, projectID, ...item }),
|
|
)
|
|
|
|
const mock = await mockOpenCodeServer(page, {
|
|
provider: provider(),
|
|
pageMessages: () => ({ items: [] }),
|
|
...input,
|
|
directory,
|
|
project: project({ id: projectID, directory, name: input.name, ...input.project }),
|
|
sessions,
|
|
})
|
|
|
|
await seed(page, {
|
|
usedProjects: { local: [projectID] },
|
|
tabs: sessions.map((item) => item.id),
|
|
...input.seed,
|
|
})
|
|
|
|
return { server: SERVER, directory, projectID, sessions, persistentPty: mock.persistentPty, push: mock.push }
|
|
}
|
|
|
|
// Opens a session (default: the first) and waits until its composer accepts input.
|
|
export async function openSession(page: Page, input: WorkspaceInput & { sessionID?: string }) {
|
|
const workspace = await mockWorkspace(page, input)
|
|
const target = workspace.sessions.find((item) => item.id === (input.sessionID ?? workspace.sessions[0]?.id))
|
|
|
|
if (!target) throw new Error(`Unknown session ${input.sessionID}`)
|
|
await page.goto(sessionHref(target.id))
|
|
await expectSessionTitle(page, target.title)
|
|
const editor = page.locator('[data-component="composer-editor"]')
|
|
await expect(editor).toBeEditable({ timeout: APP_READY_TIMEOUT })
|
|
|
|
return { ...workspace, session: target, editor }
|
|
}
|
|
|
|
// Opens a new-session draft (no sessions by default) and waits until its composer accepts input.
|
|
export async function openDraft(page: Page, input: WorkspaceInput & { draftID?: string }) {
|
|
const draftID = input.draftID ?? `draft_${input.name.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`
|
|
const directory = input.directory ?? `C:/OpenCode/${input.name}`
|
|
const sessions = input.sessions ?? []
|
|
const tabs: TabSeed[] = [...sessions.map((item) => item.id), { draft: draftID, directory }]
|
|
const workspace = await mockWorkspace(page, { ...input, directory, sessions, seed: { tabs, ...input.seed } })
|
|
await page.goto(draftHref(draftID))
|
|
const editor = page.locator('[data-component="composer-editor"]')
|
|
await expect(editor).toBeEditable({ timeout: APP_READY_TIMEOUT })
|
|
|
|
return { ...workspace, draftID, editor }
|
|
}
|
|
|
|
// Opens a draft on the local project with "New worktree" selected. Worktree creation stays pending until the test
|
|
// resolves `worktree`; POST order (`worktree`, `session`, `prompt`), worktree requests, session creates and prompts are
|
|
// recorded. The draft tab comes first, then a tab per session.
|
|
// The new-session trigger that shows the draft's project and opens the location dialog on its projects.
|
|
export function newSessionLocation(page: Page) {
|
|
return page.locator('[data-component="new-session"] [data-action="prompt-project"]')
|
|
}
|
|
|
|
// The new-session trigger that shows the draft's worktree, or Local, and opens the project's worktree step.
|
|
export function newSessionWorktree(page: Page) {
|
|
return page.locator('[data-component="new-session"] [data-action="prompt-worktree"]')
|
|
}
|
|
|
|
export async function openWorktreeDraft(page: Page, input: WorkspaceInput & { draftID?: string }) {
|
|
const worktree = Promise.withResolvers<{ status: number; json: unknown }>()
|
|
const calls: string[] = []
|
|
const worktreeRequests: { url: URL; body: Schema.JsonObject }[] = []
|
|
const creates: Schema.JsonObject[] = []
|
|
const prompts: { sessionID: string; body: Schema.JsonObject }[] = []
|
|
page.on("request", (request) => {
|
|
if (request.method() !== "POST") return
|
|
const url = new URL(request.url())
|
|
|
|
if (url.pathname === "/api/worktree") {
|
|
calls.push("worktree")
|
|
worktreeRequests.push({ url, body: request.postDataJSON() })
|
|
}
|
|
|
|
if (url.pathname === "/api/session") calls.push("session")
|
|
|
|
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) calls.push("prompt")
|
|
})
|
|
const draftID = input.draftID ?? `draft_${input.name.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`
|
|
const directory = input.directory ?? `C:/OpenCode/${input.name}`
|
|
const sessions = input.sessions ?? []
|
|
|
|
const workspace = await mockWorkspace(page, {
|
|
...input,
|
|
directory,
|
|
sessions,
|
|
onPrompt: (prompt) => {
|
|
prompts.push(prompt)
|
|
input.onPrompt?.(prompt)
|
|
},
|
|
onWorktreeCreate: async () => {
|
|
const answer = await worktree.promise
|
|
|
|
return { status: answer.status, body: answer.json }
|
|
},
|
|
onSessionCreate: (body, attempt) => {
|
|
creates.push(body)
|
|
|
|
return input.onSessionCreate?.(body, attempt)
|
|
},
|
|
seed: { tabs: [{ draft: draftID, directory }, ...sessions.map((item) => item.id)], ...input.seed },
|
|
})
|
|
|
|
await page.goto(draftHref(draftID))
|
|
const editor = page.locator('[data-component="composer-editor"]')
|
|
await expect(editor).toBeVisible({ timeout: APP_READY_TIMEOUT })
|
|
await newSessionWorktree(page).click()
|
|
await expect(page.getByRole("combobox", { name: "Search worktrees", exact: true })).toBeFocused()
|
|
await page.getByRole("option", { name: /^New worktree/ }).click()
|
|
await expect(newSessionWorktree(page)).toContainText("New worktree")
|
|
await expect(editor).toBeEditable()
|
|
|
|
return { ...workspace, draftID, editor, worktree, calls, worktreeRequests, creates, prompts }
|
|
}
|
|
|
|
// A second single-project server (default `REMOTE_SERVER`) listed in the server picker; seeds merge with
|
|
// `openSettings`, `openSession`, and `mockWorkspace`.
|
|
export async function mockRemoteServer(
|
|
page: Page,
|
|
input: Partial<Omit<MockServerConfig, "server">> & { server?: string; name?: string } = {},
|
|
) {
|
|
const server = input.server ?? REMOTE_SERVER
|
|
const directory = input.directory ?? "/remote/project"
|
|
|
|
const mock = await mockOpenCodeServer(page, {
|
|
project: project({ id: "proj_remote", directory }),
|
|
provider: NO_PROVIDER,
|
|
sessions: [],
|
|
pageMessages: () => ({ items: [] }),
|
|
...input,
|
|
server,
|
|
directory,
|
|
})
|
|
|
|
await seed(page, { servers: [input.name ? { url: server, name: input.name } : server] })
|
|
|
|
return mock
|
|
}
|
|
|
|
// Opens Settings from Home (through the Tabs drawer below 800px) and waits until it has focus.
|
|
export async function openSettings(page: Page, input: WorkspaceInput) {
|
|
const workspace = await mockWorkspace(page, { sessions: [], ...input })
|
|
await page.goto("/")
|
|
|
|
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
|
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
|
const settings = page.getByTestId("settings-screen")
|
|
await expect(settings).toBeFocused({ timeout: APP_READY_TIMEOUT })
|
|
|
|
return { ...workspace, settings }
|
|
}
|
|
|
|
export function fileNode(directory: string, path: string, type: "file" | "directory" = "file") {
|
|
return { name: path.split("/").at(-1) ?? path, path, absolute: `${directory}/${path}`, type, ignored: false }
|
|
}
|
|
|
|
// A one-line change; `loaded: false` returns only the patch header, as for a diff whose body is still loading.
|
|
export function fileDiff(
|
|
file: string,
|
|
options: { additions?: number; deletions?: number; status?: "added" | "modified" | "deleted"; loaded?: boolean } = {},
|
|
) {
|
|
const header = `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`
|
|
|
|
return {
|
|
file,
|
|
additions: options.additions ?? 1,
|
|
deletions: options.deletions ?? 1,
|
|
status: options.status ?? "modified",
|
|
patch:
|
|
options.loaded === false
|
|
? header
|
|
: `${header}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
|
}
|
|
}
|