Skip to content
Merged
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
65 changes: 65 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,54 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun

- name: Install Playwright browsers
working-directory: packages/app
run: bunx playwright install --with-deps

- name: Seed opencode data
working-directory: packages/opencode
run: bun script/seed-e2e.ts
env:
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"

- name: Run opencode server
run: bun run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 &
env:
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
OPENCODE_CLIENT: "app"

- name: Wait for opencode server
run: |
for i in {1..60}; do
curl -fsS "http://localhost:4096/global/health" > /dev/null && exit 0
sleep 1
done
exit 1

- name: run
run: |
git config --global user.email "bot@opencode.ai"
Expand All @@ -26,3 +74,20 @@ jobs:
bun turbo test
env:
CI: true
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
PLAYWRIGHT_SERVER_HOST: "localhost"
PLAYWRIGHT_SERVER_PORT: "4096"
VITE_OPENCODE_SERVER_HOST: "localhost"
VITE_OPENCODE_SERVER_PORT: "4096"
OPENCODE_CLIENT: "app"
timeout-minutes: 30
10 changes: 10 additions & 0 deletions bun.lock

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

6 changes: 3 additions & 3 deletions flake.lock

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

8 changes: 4 additions & 4 deletions nix/hashes.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-D1VXuKJagfq3mxh8Xs8naHoYNJUJzAM9JLJqpHcItDk=",
"aarch64-linux": "sha256-9wXcg50Sv56Wb2x5NWe15olNGE/uMiDkmGRmqPoeW1U=",
"aarch64-darwin": "sha256-i5eTTjsNAARwcw69sd6wuse2BKTUi/Vfgo4M28l+RoY=",
"x86_64-darwin": "sha256-oFtQnIzgTS2zcjkhBTnXxYqr20KXdA2I+b908piLs+c="
"x86_64-linux": "sha256-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=",
"aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=",
"aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=",
"x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg="
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"@playwright/test": "1.51.0",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
src/assets/theme.css
e2e/test-results
e2e/playwright-report
15 changes: 15 additions & 0 deletions packages/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ It correctly bundles Solid in production mode and optimizes the build for the be
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!

## E2E Testing

The Playwright runner expects the app already running at `http://localhost:3000`.

```bash
bun add -D @playwright/test
bunx playwright install
bun run test:e2e
```

Environment options:

- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`)
- `PLAYWRIGHT_PORT` (default: `3000`)

## Deployment

You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
45 changes: 45 additions & 0 deletions packages/app/e2e/context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { test, expect } from "./fixtures"
import { promptSelector } from "./utils"

test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}`
const created = await sdk.session.create({ title }).then((r) => r.data)

if (!created?.id) throw new Error("Session create did not return an id")
const sessionID = created.id

try {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})

await expect
.poll(async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)

await gotoSession(sessionID)

const contextButton = page
.locator('[data-component="button"]')
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()

await expect(contextButton).toBeVisible()
await contextButton.click()

const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
})
23 changes: 23 additions & 0 deletions packages/app/e2e/file-open.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { test, expect } from "./fixtures"
import { modKey } from "./utils"

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 input = dialog.getByRole("textbox").first()
await input.fill("package.json")

const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(fileItem).toBeVisible()
await fileItem.click()

await expect(dialog).toHaveCount(0)

const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible()
})
40 changes: 40 additions & 0 deletions packages/app/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { test as base, expect } from "@playwright/test"
import { createSdk, dirSlug, getWorktree, promptSelector, sessionPath } from "./utils"

type TestFixtures = {
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
}

type WorkerFixtures = {
directory: string
slug: string
}

export const test = base.extend<TestFixtures, WorkerFixtures>({
directory: [
async ({}, use) => {
const directory = await getWorktree()
await use(directory)
},
{ scope: "worker" },
],
slug: [
async ({ directory }, use) => {
await use(dirSlug(directory))
},
{ scope: "worker" },
],
sdk: async ({ directory }, use) => {
await use(createSdk(directory))
},
gotoSession: async ({ page, directory }, use) => {
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
}
await use(gotoSession)
},
})

export { expect }
21 changes: 21 additions & 0 deletions packages/app/e2e/home.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { test, expect } from "./fixtures"
import { serverName } from "./utils"

test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")

await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: serverName })).toBeVisible()
})

test("server picker dialog opens from home", async ({ page }) => {
await page.goto("/")

const trigger = page.getByRole("button", { name: serverName })
await expect(trigger).toBeVisible()
await trigger.click()

const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
})
9 changes: 9 additions & 0 deletions packages/app/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { test, expect } from "./fixtures"
import { dirPath, promptSelector } from "./utils"

test("project route redirects to /session", async ({ page, directory, slug }) => {
await page.goto(dirPath(directory))

await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await expect(page.locator(promptSelector)).toBeVisible()
})
15 changes: 15 additions & 0 deletions packages/app/e2e/palette.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { test, expect } from "./fixtures"
import { modKey } from "./utils"

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

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

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

await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
21 changes: 21 additions & 0 deletions packages/app/e2e/session.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { test, expect } from "./fixtures"
import { promptSelector } from "./utils"

test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke ${Date.now()}`
const created = await sdk.session.create({ title }).then((r) => r.data)

if (!created?.id) throw new Error("Session create did not return an id")
const sessionID = created.id

try {
await gotoSession(sessionID)

const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("hello from e2e")
await expect(prompt).toContainText("hello from e2e")
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
})
21 changes: 21 additions & 0 deletions packages/app/e2e/sidebar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { test, expect } from "./fixtures"
import { modKey } from "./utils"

test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession()

const main = page.locator("main")
const closedClass = /xl:border-l/
const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))

if (isClosed) {
await page.keyboard.press(`${modKey}+B`)
await expect(main).not.toHaveClass(closedClass)
}

await page.keyboard.press(`${modKey}+B`)
await expect(main).toHaveClass(closedClass)

await page.keyboard.press(`${modKey}+B`)
await expect(main).not.toHaveClass(closedClass)
})
Loading
Loading