From 86c782bbfdd0dd7540c543135806e1f70ef6fb21 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Sun, 31 May 2026 07:44:45 +0300 Subject: [PATCH 1/4] feat(e2e): add Playwright tests against Docker Compose stack Closes #8 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 44 +++++++++++++++++++++++- e2e/docker-compose.yml | 27 +++++++++++++++ e2e/fixtures.ts | 22 ++++++++++++ e2e/global-setup.ts | 22 ++++++++++++ e2e/smoke.spec.ts | 72 ++++++++++++++++++++++++++++++++++++++++ package.json | 1 + playwright.config.ts | 23 +++++++++++++ 7 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 e2e/docker-compose.yml create mode 100644 e2e/fixtures.ts create mode 100644 e2e/global-setup.ts create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef45c82..c9706e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ # CI pipeline for the OpenDecree Admin GUI (React + Vite + Tailwind). # # Jobs: lint, typecheck, test, build → check (alls-green gate) +# e2e (Playwright against Docker stack) also feeds into check # The check job aggregates all results for branch protection. # # The first job defines YAML anchors (&checkout, &setup-node-22, &install) @@ -73,10 +74,51 @@ jobs: - name: Build production bundle run: npm run build + e2e: + name: E2E (Playwright) + needs: [lint, typecheck, test, build] + runs-on: ubuntu-latest + steps: + - *checkout + - *setup-node-22 + - *install + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Log in to ghcr.io + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Start stack + run: docker compose -f e2e/docker-compose.yml up -d --build + + - name: Run Playwright tests + run: npx playwright test + env: + BASE_URL: http://localhost:3000 + API_URL: http://localhost:8080 + CI: "true" + + - name: Stop stack + if: always() + run: docker compose -f e2e/docker-compose.yml down + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + check: name: CI check if: always() - needs: [lint, typecheck, test, build] + needs: [lint, typecheck, test, build, e2e] runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@release/v1 diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 0000000..241bf01 --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,27 @@ +services: + server: + image: ghcr.io/opendecree/decree:main + environment: + STORAGE_BACKEND: memory + HTTP_PORT: "8080" + GRPC_PORT: "9090" + INSECURE_LISTEN: "1" + DECREE_GATEWAY_TRUSTED_PROXY: "1" + ENABLE_SERVICES: schema,config,audit + ports: + - "8080:8080" + + ui: + build: + context: .. + environment: + API_URL: http://server:8080 + ports: + - "3000:80" + depends_on: + - server + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost/ > /dev/null 2>&1 || exit 1"] + interval: 5s + timeout: 5s + retries: 12 diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..e07be81 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,22 @@ +import { test as base, type Page } from "@playwright/test"; + +export const API_URL = process.env.API_URL ?? "http://localhost:8080"; +export const AUTH_HEADERS = { "x-subject": "admin", "x-role": "superadmin" }; + +async function injectAuth(page: Page) { + await page.addInitScript(() => { + localStorage.setItem( + "decree-auth", + JSON.stringify({ subject: "admin", role: "superadmin" }), + ); + }); +} + +export const test = base.extend<{ page: Page }>({ + page: async ({ page }, use) => { + await injectAuth(page); + await use(page); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..fc28d0b --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,22 @@ +const API_URL = process.env.API_URL ?? "http://localhost:8080"; +const UI_URL = process.env.BASE_URL ?? "http://localhost:3000"; +const AUTH_HEADERS = { "x-subject": "admin", "x-role": "superadmin" }; +const MAX_WAIT_MS = 60_000; +const POLL_MS = 1_000; + +async function waitFor(name: string, url: string, headers?: Record) { + const start = Date.now(); + while (Date.now() - start < MAX_WAIT_MS) { + try { + const res = await fetch(url, { headers }); + if (res.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, POLL_MS)); + } + throw new Error(`${name} not ready after ${MAX_WAIT_MS}ms at ${url}`); +} + +export default async function globalSetup() { + await waitFor("decree server", `${API_URL}/v1/schemas`, AUTH_HEADERS); + await waitFor("decree-ui", UI_URL); +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..91bbec9 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,72 @@ +import { expect, test, API_URL, AUTH_HEADERS } from "./fixtures"; + +test("home page loads", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("home-page")).toBeVisible(); +}); + +test("schemas page loads", async ({ page }) => { + await page.goto("/schemas"); + await expect(page.getByTestId("schema-list-page")).toBeVisible(); +}); + +test("import schema via UI form", async ({ page }) => { + await page.goto("/schemas/import"); + + const yaml = [ + "syntax: v1", + "name: e2e-smoke", + "fields:", + " - path: feature.enabled", + " type: bool", + ].join("\n"); + + await page.locator("#yaml-input").fill(yaml); + await page.locator("#auto-publish").check(); + await page.getByRole("button", { name: "Import" }).click(); + + await expect(page.getByText("Schema imported successfully")).toBeVisible(); +}); + +test("imported schema appears in schemas list", async ({ page }) => { + await page.goto("/schemas"); + await expect(page.getByTestId("schema-list-page")).toBeVisible(); + await expect(page.getByRole("link", { name: "e2e-smoke" })).toBeVisible(); +}); + +test("tenant config page loads", async ({ page, request }) => { + const yamlB64 = Buffer.from( + "syntax: v1\nname: e2e-config\nfields:\n - path: x\n type: string", + ).toString("base64"); + + const importRes = await request.post(`${API_URL}/v1/schemas/import`, { + headers: { ...AUTH_HEADERS, "content-type": "application/json" }, + data: { yamlContent: yamlB64, autoPublish: true }, + }); + expect(importRes.ok()).toBeTruthy(); + const { schema } = await importRes.json(); + + const tenantRes = await request.post(`${API_URL}/v1/tenants`, { + headers: { ...AUTH_HEADERS, "content-type": "application/json" }, + data: { name: "e2e-tenant", schemaId: schema.id, schemaVersion: 1 }, + }); + expect(tenantRes.ok()).toBeTruthy(); + const { tenant } = await tenantRes.json(); + + await page.goto(`/tenants/${tenant.id}`); + await expect(page.getByTestId("tenant-detail-page")).toBeVisible(); +}); + +test("API calls succeed through nginx proxy", async ({ page }) => { + await page.goto("/schemas"); + + const result = await page.evaluate(async () => { + const res = await fetch("/v1/schemas", { + headers: { "x-subject": "admin", "x-role": "superadmin" }, + }); + return { status: res.status, ok: res.ok }; + }); + + expect(result.ok).toBe(true); + expect(result.status).toBe(200); +}); diff --git a/package.json b/package.json index d043216..73bfc2e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "screenshots": "tsx scripts/screenshots.ts", "screenshots:full": "bash scripts/take-screenshots.sh", "screenshots:rebuild": "bash scripts/take-screenshots.sh --rebuild", + "test:e2e": "playwright test", "pre-commit": "biome check src/ && tsc -b --noEmit && vitest run && npm run build" }, "dependencies": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..3a3b34a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000"; + +export default defineConfig({ + testDir: "e2e", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: BASE_URL, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + globalSetup: "./e2e/global-setup.ts", +}); From befc7f63ade46184b39c0784fa3db3408b989caa Mon Sep 17 00:00:00 2001 From: zeevdr Date: Sun, 31 May 2026 07:48:38 +0300 Subject: [PATCH 2/4] fix(test): exclude e2e/ from vitest glob Co-Authored-By: Claude Sonnet 4.6 --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.config.ts b/vitest.config.ts index 247de21..c52da8c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ environment: "jsdom", globals: true, setupFiles: ["./src/test-setup.ts"], + exclude: ["node_modules", "e2e/**"], coverage: { provider: "v8", include: ["src/**/*.{ts,tsx}"], From 7f0301eba715197e2f1e1794673f7bd50b37a8ca Mon Sep 17 00:00:00 2001 From: zeevdr Date: Sun, 31 May 2026 07:53:20 +0300 Subject: [PATCH 3/4] fix(e2e): use correct spec_version YAML format for import Also fix the SchemaImport placeholder which showed the wrong format. The server requires spec_version: v1 with map-style fields, not syntax: v1. Co-Authored-By: Claude Sonnet 4.6 --- e2e/smoke.spec.ts | 6 +++--- src/pages/schemas/SchemaImport.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 91bbec9..2fa6a56 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -14,10 +14,10 @@ test("import schema via UI form", async ({ page }) => { await page.goto("/schemas/import"); const yaml = [ - "syntax: v1", + "spec_version: v1", "name: e2e-smoke", "fields:", - " - path: feature.enabled", + " feature.enabled:", " type: bool", ].join("\n"); @@ -36,7 +36,7 @@ test("imported schema appears in schemas list", async ({ page }) => { test("tenant config page loads", async ({ page, request }) => { const yamlB64 = Buffer.from( - "syntax: v1\nname: e2e-config\nfields:\n - path: x\n type: string", + "spec_version: v1\nname: e2e-config\nfields:\n x:\n type: string", ).toString("base64"); const importRes = await request.post(`${API_URL}/v1/schemas/import`, { diff --git a/src/pages/schemas/SchemaImport.tsx b/src/pages/schemas/SchemaImport.tsx index 9fa9c23..835377d 100644 --- a/src/pages/schemas/SchemaImport.tsx +++ b/src/pages/schemas/SchemaImport.tsx @@ -68,7 +68,7 @@ export function SchemaImport() { value={yaml} onChange={(e) => setYaml(e.target.value)} rows={16} - placeholder={`syntax: v1\nname: my-schema\nfields:\n - path: feature.enabled\n type: bool`} + placeholder={`spec_version: v1\nname: my-schema\nfields:\n feature.enabled:\n type: bool`} className="w-full rounded border border-gray-300 px-3 py-2 font-mono text-sm dark:border-gray-700 dark:bg-gray-800" /> From 51ed57b4af76c7ab0643488cb8279dfa446ae2f4 Mon Sep 17 00:00:00 2001 From: zeevdr Date: Sun, 31 May 2026 07:58:12 +0300 Subject: [PATCH 4/4] fix(import): base64-encode yamlContent before sending to API gRPC-gateway transcodes proto bytes fields as base64 in JSON. The import form was sending raw YAML (plain text), which the server rejected at the JSON unmarshal stage. Co-Authored-By: Claude Sonnet 4.6 --- src/pages/schemas/SchemaImport.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/schemas/SchemaImport.tsx b/src/pages/schemas/SchemaImport.tsx index 835377d..ecebd52 100644 --- a/src/pages/schemas/SchemaImport.tsx +++ b/src/pages/schemas/SchemaImport.tsx @@ -17,7 +17,7 @@ export function SchemaImport() { const importMutation = useMutation({ mutationFn: async () => { const { data, error } = await client.POST("/v1/schemas/import", { - body: { yamlContent: yaml, autoPublish }, + body: { yamlContent: btoa(yaml), autoPublish }, }); if (error) throw new Error(formatError(error)); return data;