diff --git a/tests/e2e/README.md b/tests/e2e/README.md index ea8ac99..1fec514 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -3,10 +3,11 @@ Run these after starting a generated app: ```sh -cd tests/e2e -npx playwright test +npm --prefix tests/e2e run test:local ``` Set `GOMYADMIN_E2E_BASE_URL` when the frontend is not running on `http://localhost:3000`. -End-to-end browser tests should cover login, resource listing, filtering, sorting, custom actions, audit visibility, and file upload once Playwright is added to the frontend template. +The current suite mocks the admin API and covers login, resource listing, search, filtering, create/update flow, and audit log visibility for the generated CRM demo. Playwright writes screenshots, videos, and traces to `tests/e2e/test-results` when a test fails. + +CI can run the same command after starting the generated Next.js app; wiring that app startup into repository CI is a follow-up because the template is not generated as part of the default Go checks yet. diff --git a/tests/e2e/admin.spec.ts b/tests/e2e/admin.spec.ts index 37458ab..8aeebf3 100644 --- a/tests/e2e/admin.spec.ts +++ b/tests/e2e/admin.spec.ts @@ -1,9 +1,245 @@ -import { expect, test } from "@playwright/test" +import { expect, test, type Page, type Route } from "@playwright/test" -test("admin shell renders login and dashboard routes", async ({ page }) => { +type ApiPayload = Record +type UserRecord = { + id: string + email: string + name: string + role: string + status: string + created_at: string +} +type AuditRecord = { + id: string + actor_email: string + action: string + resource: string + old_values: ApiPayload | null + new_values: ApiPayload | null + metadata: ApiPayload + created_at: string +} + +const resources = [ + { + name: "users", + label: "Users", + icon: "users", + description: "Administrators and operators who can access the workspace.", + actions: [], + fields: [ + { name: "id", label: "ID", type: "string", searchable: false, sortable: false, filterable: false, readonly: true, hidden: true }, + { name: "email", label: "Email", type: "email", searchable: true, sortable: true, filterable: false, readonly: false, hidden: false }, + { name: "name", label: "Name", type: "string", searchable: true, sortable: true, filterable: false, readonly: false, hidden: false }, + { name: "role", label: "Role", type: "enum", searchable: false, sortable: false, filterable: true, readonly: false, hidden: false, enum_values: ["admin", "manager", "support", "viewer"] }, + { name: "status", label: "Status", type: "enum", searchable: false, sortable: false, filterable: true, readonly: false, hidden: false, enum_values: ["active", "blocked", "pending"] }, + { name: "created_at", label: "Created", type: "datetime", searchable: false, sortable: true, filterable: false, readonly: true, hidden: false } + ] + } +] + +function apiResponse(data: T, meta: ApiPayload = {}, error: null | { code: string; message: string } = null) { + return { + data, + meta, + error + } +} + +async function fulfill(route: Route, data: unknown, meta?: ApiPayload) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(apiResponse(data, meta)) + }) +} + +async function mockAdminAPI(page: Page) { + const users: UserRecord[] = [ + { + id: "usr_1", + email: "avery@example.com", + name: "Avery Stone", + role: "admin", + status: "active", + created_at: "2026-01-05T10:00:00Z" + }, + { + id: "usr_2", + email: "blake@example.com", + name: "Blake Wong", + role: "support", + status: "blocked", + created_at: "2026-01-04T10:00:00Z" + } + ] + const audit: AuditRecord[] = [ + { + id: "evt_1", + actor_email: "admin@example.com", + action: "user.created", + resource: "users", + old_values: null, + new_values: { id: "usr_2", email: "blake@example.com" }, + metadata: { ip: "127.0.0.1" }, + created_at: "2026-01-05T11:00:00Z" + } + ] + + await page.route("**/admin/api/**", async (route) => { + const request = route.request() + const url = new URL(request.url()) + const method = request.method() + const path = url.pathname + + if (path === "/admin/api/auth/providers" && method === "GET") return fulfill(route, []) + if (path === "/admin/api/auth/login" && method === "POST") return fulfill(route, { user: { id: "admin_1", email: "admin@example.com" } }) + if (path === "/admin/api/resources" && method === "GET") return fulfill(route, resources) + if (path === "/admin/api/audit" && method === "GET") return fulfill(route, audit) + + if (path === "/admin/api/users" && method === "GET") { + const search = url.searchParams.get("q")?.toLowerCase() + const status = url.searchParams.get("filter[status][eq]") + const filtered = users.filter((user) => { + const matchesSearch = search ? user.name.toLowerCase().includes(search) || user.email.toLowerCase().includes(search) : true + const matchesStatus = status ? user.status === status : true + return matchesSearch && matchesStatus + }) + return fulfill(route, filtered, { total: filtered.length }) + } + + if (path === "/admin/api/users" && method === "POST") { + const payload = request.postDataJSON() as ApiPayload + const created: UserRecord = { + id: "usr_3", + email: String(payload.email ?? ""), + name: String(payload.name ?? ""), + role: String(payload.role ?? ""), + status: String(payload.status ?? ""), + created_at: "2026-01-06T10:00:00Z" + } + users.unshift(created) + audit.unshift({ + id: "evt_2", + actor_email: "admin@example.com", + action: "user.created", + resource: "users", + old_values: null, + new_values: created, + metadata: { source: "e2e" }, + created_at: "2026-01-06T10:01:00Z" + }) + return fulfill(route, created) + } + + const userPath = path.match(/^\/admin\/api\/users\/([^/]+)$/) + if (userPath && method === "GET") { + const user = users.find((item) => item.id === userPath[1]) + return user ? fulfill(route, user) : route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify(apiResponse(null, {}, { code: "not_found", message: "User not found" })) }) + } + + if (userPath && method === "PATCH") { + const payload = request.postDataJSON() as ApiPayload + const index = users.findIndex((item) => item.id === userPath[1]) + if (index === -1) return route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify(apiResponse(null, {}, { code: "not_found", message: "User not found" })) }) + const previous = users[index] + const updated: UserRecord = { + ...previous, + email: typeof payload.email === "string" ? payload.email : previous.email, + name: typeof payload.name === "string" ? payload.name : previous.name, + role: typeof payload.role === "string" ? payload.role : previous.role, + status: typeof payload.status === "string" ? payload.status : previous.status + } + users[index] = updated + audit.unshift({ + id: "evt_3", + actor_email: "admin@example.com", + action: "user.updated", + resource: "users", + old_values: { id: updated.id }, + new_values: updated, + metadata: { source: "e2e" }, + created_at: "2026-01-06T10:02:00Z" + }) + return fulfill(route, updated) + } + + return route.fallback() + }) +} + +test.beforeEach(async ({ page }) => { + await mockAdminAPI(page) +}) + +test("logs in and loads the CRM resource list", async ({ page }) => { await page.goto("/admin/login") - await expect(page.getByRole("heading", { name: /sign in/i })).toBeVisible() + await expect(page.getByRole("heading", { name: "GoMyAdmin" })).toBeVisible() + + await page.getByLabel("Email").fill("admin@example.com") + await page.getByLabel("Password").fill("password") + await page.getByRole("button", { name: /continue/i }).click() + + await expect(page).toHaveURL(/\/admin\/dashboard$/) + await page.goto("/admin/resources/users") + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible() + await expect(page.getByRole("cell", { name: "avery@example.com" })).toBeVisible() + await expect(page.getByText("Page 1 of 1 · 2 records")).toBeVisible() +}) + +test("searches and filters the resource list", async ({ page }) => { + await page.goto("/admin/resources/users") + + await page.getByPlaceholder("Search users").fill("avery") + await expect(page.getByRole("cell", { name: "avery@example.com" })).toBeVisible() + await expect(page.getByRole("cell", { name: "blake@example.com" })).toHaveCount(0) + await expect(page.getByText("Page 1 of 1 · 1 records")).toBeVisible() + + await page.getByPlaceholder("Search users").fill("") + await page.getByLabel("Clear filter").click() + await page.getByRole("combobox").first().selectOption("status") + await page.getByPlaceholder("Value").fill("blocked") + await page.getByRole("button", { name: "Apply" }).click() + + await expect(page.getByRole("cell", { name: "blake@example.com" })).toBeVisible() + await expect(page.getByRole("cell", { name: "avery@example.com" })).toHaveCount(0) +}) + +test("creates and updates a CRM record", async ({ page }) => { + await page.goto("/admin/resources/users/new") + + await expect(page.getByRole("heading", { name: "New Users" })).toBeVisible() + await page.getByLabel("Email").fill("casey@example.com") + await page.getByLabel("Name").fill("Casey Admin") + await page.getByLabel("Role").selectOption("manager") + await page.getByLabel("Status").selectOption("pending") + await page.getByRole("button", { name: "Save" }).click() + + await expect(page).toHaveURL(/\/admin\/resources\/users$/) + await expect(page.getByRole("cell", { name: "casey@example.com" })).toBeVisible() + + await page.goto("/admin/resources/users/usr_3/edit") + await expect(page.getByRole("heading", { name: "Edit Users" })).toBeVisible() + await page.getByLabel("Name").fill("Casey Operator") + await page.getByLabel("Status").selectOption("active") + await page.getByRole("button", { name: "Save" }).click() + + await expect(page).toHaveURL(/\/admin\/resources\/users$/) + await expect(page.getByRole("cell", { name: "Casey Operator" })).toBeVisible() +}) + +test("shows resource mutations in the audit log", async ({ page }) => { + await page.goto("/admin/resources/users/new") + await page.getByLabel("Email").fill("delta@example.com") + await page.getByLabel("Name").fill("Delta Support") + await page.getByLabel("Role").selectOption("support") + await page.getByLabel("Status").selectOption("active") + await page.getByRole("button", { name: "Save" }).click() + + await page.goto("/admin/audit") - await page.goto("/admin/dashboard") - await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible() + await expect(page.getByRole("heading", { name: "Audit log" })).toBeVisible() + await expect(page.getByText("user.created · users").first()).toBeVisible() + await expect(page.getByText("delta@example.com")).toBeVisible() }) diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..992ecdf --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,109 @@ +{ + "name": "gomyadmin-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gomyadmin-e2e", + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/node": "^22.0.0", + "typescript": "^6.0.3" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..7d37954 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "gomyadmin-e2e", + "private": true, + "scripts": { + "test": "playwright test", + "test:local": "npm install && playwright test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/node": "^22.0.0", + "typescript": "^6.0.3" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 1554298..ce402bb 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -2,10 +2,13 @@ import { defineConfig, devices } from "@playwright/test" export default defineConfig({ testDir: ".", + outputDir: "test-results", timeout: 30_000, use: { baseURL: process.env.GOMYADMIN_E2E_BASE_URL ?? "http://localhost:3000", - trace: "on-first-retry" + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure" }, projects: [ { diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..600ed8e --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "ESNext"], + "types": ["node"], + "strict": true, + "noEmit": true + }, + "include": ["*.ts"] +}