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
44 changes: 43 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -73,10 +74,51 @@
- 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:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {}
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
Expand Down
27 changes: 27 additions & 0 deletions e2e/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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";
22 changes: 22 additions & 0 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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);
}
72 changes: 72 additions & 0 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = [
"spec_version: v1",
"name: e2e-smoke",
"fields:",
" 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(
"spec_version: v1\nname: e2e-config\nfields:\n 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);
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
23 changes: 23 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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",
});
4 changes: 2 additions & 2 deletions src/pages/schemas/SchemaImport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
/>
</div>
Expand Down
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}"],
Expand Down
Loading