opencode/packages/app/e2e/utils/app.ts
Dax Raad 7aac8f71d9 feat(app): use server projects and track used projects on home
- The GUI project list comes from the server (GET /api/project) everywhere, in
  its order (most recently active first), and follows project.updated: unknown
  projects join, archived ones leave, activity re-sorts. "Add project" resolves
  the directory on the server and reloads the list.
- Home lists only the server projects this client has used (per server, by
  project ID), with a Close menu item that hides a project until it is used
  again. The new-session location dialog lists used projects first, then the
  rest; Settings and the command palette keep the full list.
- Projects sharing one directory under several IDs collapse to the most
  recently active one on Home and in the location dialog.
- The directory list older builds stored is migrated once per server to used
  project IDs after the server list loads, then dropped.
- Removed local project reordering, recently closed, close-as-hide of the
  local list, the per-directory icon override cache and the opencode-test path
  filter.
2026-10-18 23:05:51 -04:00

227 lines
8.1 KiB
TypeScript

import { expect, type Page, type Request } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode, checksum } from "@opencode/util/encode"
// The mocked default server. Production builds connect to their own origin, so CI points this at the app.
export const SERVER = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
export const REMOTE_SERVER = "http://127.0.0.1:4097"
export const T0 = 1700000000000
export function sessionHref(sessionID: string, server = SERVER) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
export function draftHref(draftID: string) {
return `/new-session?draftId=${encodeURIComponent(draftID)}`
}
// Key of `opencode.window.browser.dat:tabs.panes` entries.
export function tabKey(sessionID: string, server = SERVER) {
return `${server}\n${sessionHref(sessionID, server)}`
}
// A default-server workspace storage key, as the app writes it for a forward-slash directory without a trailing slash.
export function workspaceKey(directory: string, key: string) {
const head = directory.slice(0, 12).replace(/[^a-zA-Z0-9._-]/g, "-")
return `opencode.workspace.${head}.${checksum(directory) ?? "0"}.dat:workspace:${key}`
}
export async function expectPath(page: Page, href: string) {
await expect(page).toHaveURL(new RegExp(`${href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
}
export type TabSeed =
| string
| { session: string; server?: string }
| { draft: string; directory: string; server?: string }
export type SeedInput = {
servers?: (string | { url: string; name?: string })[]
// Project IDs this client has used, which Home lists. Key `local` is the default server; other keys are origins.
usedProjects?: Record<string, string[]>
// The directory list builds before the server-backed list kept, which the app migrates to used projects.
legacyProjects?: Record<string, { worktree: string; expanded?: boolean }[]>
// A string is a session ID on the default server.
tabs?: TabSeed[]
// Keyed by session ID on the default server.
panes?: Record<string, Record<string, unknown>>
settings?: Record<string, unknown>
locale?: string
theme?: { id: string; scheme: "light" | "dark" }
// Any other storage key. Strings are written as is, other values as JSON.
storage?: Record<string, unknown>
}
const seeds = { next: 0 }
// Writes browser storage before the app boots, once per tab: reloads and later navigations keep what the app stored.
// Object values merge one level deep into what an earlier seed wrote, so several helpers can seed one key.
export async function seed(page: Page, input: SeedInput) {
await page.addInitScript(
({ marker, entries }) => {
if (window.top !== window) return
if (sessionStorage.getItem(marker)) return
sessionStorage.setItem(marker, "1")
const plain = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value)
entries.forEach(([key, value, merge]) => {
const current: unknown = merge ? JSON.parse(localStorage.getItem(key) ?? "null") : undefined
if (!plain(current)) return localStorage.setItem(key, value)
const next: Record<string, unknown> = JSON.parse(value)
const merged = Object.entries({ ...current, ...next }).map(([field, item]) => {
const before = current[field]
const after = next[field]
return [field, plain(before) && plain(after) ? { ...before, ...after } : item]
})
localStorage.setItem(key, JSON.stringify(Object.fromEntries(merged)))
})
},
{ marker: `opencode.e2e.seed.${seeds.next++}`, entries: storageEntries(input) },
)
}
function storageEntries(input: SeedInput): [string, string, boolean][] {
const server = {
...(input.usedProjects ? { usedProjectIDs: input.usedProjects } : {}),
...(input.legacyProjects ? { projects: input.legacyProjects } : {}),
...(input.servers
? {
list: input.servers.map((item) =>
typeof item === "string" ? item : { type: "http", displayName: item.name, http: { url: item.url } },
),
}
: {}),
}
const values: Record<string, unknown> = {
...(Object.keys(server).length ? { "opencode.global.dat:server": server } : {}),
...(input.tabs ? { "opencode.window.browser.dat:tabs": input.tabs.map(tabEntry) } : {}),
...(input.panes
? {
"opencode.window.browser.dat:tabs.panes": Object.fromEntries(
Object.entries(input.panes).map(([sessionID, pane]) => [tabKey(sessionID), pane]),
),
}
: {}),
...(input.settings ? { "settings.v3": input.settings } : {}),
...(input.locale ? { "opencode.global.dat:language": { locale: input.locale } } : {}),
...(input.theme ? { "opencode-theme-id": input.theme.id, "opencode-color-scheme": input.theme.scheme } : {}),
...input.storage,
}
return Object.entries(values).map(([key, value]) => [
key,
typeof value === "string" ? value : JSON.stringify(value),
!!value && typeof value === "object" && !Array.isArray(value),
])
}
function tabEntry(tab: TabSeed) {
if (typeof tab === "string") return { type: "session", server: SERVER, sessionId: tab }
if ("session" in tab) return { type: "session", server: tab.server ?? SERVER, sessionId: tab.session }
return { type: "draft", draftID: tab.draft, server: tab.server ?? SERVER, directory: tab.directory }
}
export function project(input: { id: string; directory: string; name?: string } & Record<string, unknown>) {
const { directory, ...rest } = input
return {
worktree: directory,
canonical: directory,
name: directory.split(/[\\/]/).at(-1),
vcs: "git",
time: { created: T0, updated: T0, active: T0 },
sandboxes: [] as string[],
...rest,
}
}
export function session(
input: { id: string; directory: string; title?: string; projectID?: string; created?: number } & Record<
string,
unknown
>,
) {
const { created = T0, ...rest } = input
return {
slug: input.id,
title: input.id,
version: "dev",
time: { created, updated: created },
...rest,
}
}
export type ModelSeed = { id: string; name: string } & Record<string, unknown>
// A connected `opencode` provider whose first model is the default.
export function provider(...models: ModelSeed[]) {
const all = models.length ? models : [{ id: "test", name: "Test" }]
return {
all: [
{
id: "opencode",
name: "OpenCode",
models: Object.fromEntries(all.map((model) => [model.id, { limit: { context: 200_000 }, ...model }])),
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: all[0]!.id },
}
}
export const NO_PROVIDER = { all: [], connected: [], default: {} }
// Pages oldest-first history the way the server does: newest `limit` items before the cursor.
export function pageMessagesFrom(messages: Record<string, SessionMessageInfo[]>) {
return (sessionID: string, limit: number, before?: string) => {
const items = messages[sessionID] ?? []
const end = before
? Math.max(
0,
items.findIndex((message) => message.id === before),
)
: items.length
const start = Math.max(0, end - limit)
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.id : undefined }
}
}
// Holds matching requests (never CORS preflights) until `release()`, then passes them to earlier routes such as the mock server.
export async function holdRoute(
page: Page,
url: string | RegExp | ((url: URL) => boolean),
options: { method?: string } = {},
) {
const released = Promise.withResolvers<void>()
const arrived = Promise.withResolvers<Request>()
const requests: Request[] = []
await page.route(url, async (route) => {
const method = route.request().method()
if (method === "OPTIONS" || (options.method && method !== options.method)) return route.fallback()
requests.push(route.request())
arrived.resolve(route.request())
await released.promise
await route.fallback().catch(() => undefined)
})
return { requests, arrived: arrived.promise, release: () => released.resolve() }
}