Procházet zdrojové kódy

test(app): initial e2e test setup

Adam před 2 měsíci
rodič
revize
03d7467ea2

+ 63 - 0
.github/workflows/test.yml

@@ -18,6 +18,52 @@ jobs:
       - name: Setup Bun
         uses: ./.github/actions/setup-bun
 
+      - name: Install Playwright browsers
+        run: bun --cwd packages/app x playwright install --with-deps
+
+      - name: Seed opencode data
+        run: bun --cwd packages/opencode 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 --cwd packages/opencode 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 "[email protected]"
@@ -26,3 +72,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 - 0
bun.lock

@@ -56,6 +56,7 @@
       },
       "devDependencies": {
         "@happy-dom/global-registrator": "20.0.11",
+        "@playwright/test": "1.57.0",
         "@tailwindcss/vite": "catalog:",
         "@tsconfig/bun": "1.0.9",
         "@types/bun": "catalog:",
@@ -502,6 +503,7 @@
     "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
     "@pierre/diffs": "1.0.2",
+    "@playwright/test": "1.51.0",
     "@solid-primitives/storage": "4.3.3",
     "@solidjs/meta": "0.29.4",
     "@solidjs/router": "0.15.4",
@@ -1355,6 +1357,8 @@
 
     "@planetscale/database": ["@planetscale/[email protected]", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
 
+    "@playwright/test": ["@playwright/[email protected]", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="],
+
     "@poppinss/colors": ["@poppinss/[email protected]", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="],
 
     "@poppinss/dumper": ["@poppinss/[email protected]", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
@@ -3291,6 +3295,10 @@
 
     "planck": ["[email protected]", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="],
 
+    "playwright": ["[email protected]", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
+
+    "playwright-core": ["[email protected]", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
+
     "pngjs": ["[email protected]", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
 
     "possible-typed-array-names": ["[email protected]", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
@@ -4427,6 +4435,8 @@
 
     "pkg-up/find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
 
+    "playwright/fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
+
     "postcss-load-config/lilconfig": ["[email protected]", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
 
     "prompts/kleur": ["[email protected]", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],

+ 1 - 0
package.json

@@ -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",

+ 2 - 0
packages/app/.gitignore

@@ -1 +1,3 @@
 src/assets/theme.css
+e2e/test-results
+e2e/playwright-report

+ 15 - 0
packages/app/README.md

@@ -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.)

+ 6 - 0
packages/app/e2e/home.spec.ts

@@ -0,0 +1,6 @@
+import { test, expect } from "@playwright/test"
+
+test("home shows recent projects header", async ({ page }) => {
+  await page.goto("/")
+  await expect(page.getByText("Recent projects")).toBeVisible()
+})

+ 6 - 1
packages/app/package.json

@@ -12,11 +12,16 @@
     "start": "vite",
     "dev": "vite",
     "build": "vite build",
-    "serve": "vite preview"
+    "serve": "vite preview",
+    "test": "playwright test",
+    "test:e2e": "playwright test",
+    "test:e2e:ui": "playwright test --ui",
+    "test:e2e:report": "playwright show-report e2e/playwright-report"
   },
   "license": "MIT",
   "devDependencies": {
     "@happy-dom/global-registrator": "20.0.11",
+    "@playwright/test": "1.57.0",
     "@tailwindcss/vite": "catalog:",
     "@tsconfig/bun": "1.0.9",
     "@types/bun": "catalog:",

+ 43 - 0
packages/app/playwright.config.ts

@@ -0,0 +1,43 @@
+import { defineConfig, devices } from "@playwright/test"
+
+const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
+const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`
+const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
+const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
+const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
+const reuse = !process.env.CI
+
+export default defineConfig({
+  testDir: "./e2e",
+  outputDir: "./e2e/test-results",
+  timeout: 60_000,
+  expect: {
+    timeout: 10_000,
+  },
+  fullyParallel: true,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 2 : 0,
+  reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
+  webServer: {
+    command,
+    url: baseURL,
+    reuseExistingServer: reuse,
+    timeout: 120_000,
+    env: {
+      VITE_OPENCODE_SERVER_HOST: serverHost,
+      VITE_OPENCODE_SERVER_PORT: serverPort,
+    },
+  },
+  use: {
+    baseURL,
+    trace: "on-first-retry",
+    screenshot: "only-on-failure",
+    video: "retain-on-failure",
+  },
+  projects: [
+    {
+      name: "chromium",
+      use: { ...devices["Desktop Chrome"] },
+    },
+  ],
+})

+ 50 - 0
packages/opencode/script/seed-e2e.ts

@@ -0,0 +1,50 @@
+const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
+const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
+const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
+const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano"
+const parts = model.split("/")
+const providerID = parts[0] ?? "opencode"
+const modelID = parts[1] ?? "gpt-5-nano"
+const now = Date.now()
+
+const seed = async () => {
+  const { Instance } = await import("../src/project/instance")
+  const { InstanceBootstrap } = await import("../src/project/bootstrap")
+  const { Session } = await import("../src/session")
+  const { Identifier } = await import("../src/id/id")
+  const { Project } = await import("../src/project/project")
+
+  await Instance.provide({
+    directory: dir,
+    init: InstanceBootstrap,
+    fn: async () => {
+      const session = await Session.create({ title })
+      const messageID = Identifier.descending("message")
+      const partID = Identifier.descending("part")
+      const message = {
+        id: messageID,
+        sessionID: session.id,
+        role: "user" as const,
+        time: { created: now },
+        agent: "build",
+        model: {
+          providerID,
+          modelID,
+        },
+      }
+      const part = {
+        id: partID,
+        sessionID: session.id,
+        messageID,
+        type: "text" as const,
+        text,
+        time: { start: now },
+      }
+      await Session.updateMessage(message)
+      await Session.updatePart(part)
+      await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
+    },
+  })
+}
+
+await seed()

+ 6 - 0
packages/opencode/src/share/share-next.ts

@@ -15,7 +15,10 @@ export namespace ShareNext {
     return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
   }
 
+  const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
+
   export async function init() {
+    if (disabled) return
     Bus.subscribe(Session.Event.Updated, async (evt) => {
       await sync(evt.properties.info.id, [
         {
@@ -63,6 +66,7 @@ export namespace ShareNext {
   }
 
   export async function create(sessionID: string) {
+    if (disabled) return { id: "", url: "", secret: "" }
     log.info("creating share", { sessionID })
     const result = await fetch(`${await url()}/api/share`, {
       method: "POST",
@@ -110,6 +114,7 @@ export namespace ShareNext {
 
   const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
   async function sync(sessionID: string, data: Data[]) {
+    if (disabled) return
     const existing = queue.get(sessionID)
     if (existing) {
       for (const item of data) {
@@ -145,6 +150,7 @@ export namespace ShareNext {
   }
 
   export async function remove(sessionID: string) {
+    if (disabled) return
     log.info("removing share", { sessionID })
     const share = await get(sessionID)
     if (!share) return

+ 5 - 0
packages/opencode/src/share/share.ts

@@ -11,6 +11,7 @@ export namespace Share {
   const pending = new Map<string, any>()
 
   export async function sync(key: string, content: any) {
+    if (disabled) return
     const [root, ...splits] = key.split("/")
     if (root !== "session") return
     const [sub, sessionID] = splits
@@ -69,7 +70,10 @@ export namespace Share {
     process.env["OPENCODE_API"] ??
     (Installation.isPreview() || Installation.isLocal() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
 
+  const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
+
   export async function create(sessionID: string) {
+    if (disabled) return { url: "", secret: "" }
     return fetch(`${URL}/share_create`, {
       method: "POST",
       body: JSON.stringify({ sessionID: sessionID }),
@@ -79,6 +83,7 @@ export namespace Share {
   }
 
   export async function remove(sessionID: string, secret: string) {
+    if (disabled) return {}
     return fetch(`${URL}/share_delete`, {
       method: "POST",
       body: JSON.stringify({ sessionID, secret }),