diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dbae9e1..e29d78bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,48 @@ jobs: bash compose.sh -- logs git-proxy-java fi + playwright-test: + name: Playwright UI Tests + runs-on: ubuntu-latest + if: > + github.ref == 'refs/heads/main' || + github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6 + + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5 + with: + distribution: temurin + java-version: 25 + cache: gradle + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # ratchet:actions/setup-node@v6 + with: + node-version: '24.15.0' + cache: npm + cache-dependency-path: git-proxy-java-dashboard/frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + working-directory: git-proxy-java-dashboard/frontend + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + working-directory: git-proxy-java-dashboard/frontend + + - name: Run Playwright tests + run: npm run test:e2e + working-directory: git-proxy-java-dashboard/frontend + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # ratchet:actions/upload-artifact@v7 + with: + name: playwright-report + path: git-proxy-java-dashboard/frontend/playwright-report/ + retention-days: 14 + dependency-submission: name: Dependency Submission runs-on: ubuntu-latest diff --git a/git-proxy-java-dashboard/frontend/.gitignore b/git-proxy-java-dashboard/frontend/.gitignore index 7dc978b3..520fbd36 100644 --- a/git-proxy-java-dashboard/frontend/.gitignore +++ b/git-proxy-java-dashboard/frontend/.gitignore @@ -30,6 +30,11 @@ vite.config.ts.timestamp-* # Local env overrides *.local +# Playwright +/test-results/ +/playwright-report/ +/tests/.auth/ + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/git-proxy-java-dashboard/frontend/package-lock.json b/git-proxy-java-dashboard/frontend/package-lock.json index 29869d06..1b8d5b20 100644 --- a/git-proxy-java-dashboard/frontend/package-lock.json +++ b/git-proxy-java-dashboard/frontend/package-lock.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@playwright/test": "^1.49.0", "@tailwindcss/vite": "^4.2.2", "@types/node": "^24.12.0", "@types/react": "^19.2.14", @@ -593,6 +594,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "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/@profoundlogic/hogan": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@profoundlogic/hogan/-/hogan-3.0.4.tgz", @@ -2980,6 +2997,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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/playwright/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/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", diff --git a/git-proxy-java-dashboard/frontend/package.json b/git-proxy-java-dashboard/frontend/package.json index e3b85976..b2a2a8e1 100644 --- a/git-proxy-java-dashboard/frontend/package.json +++ b/git-proxy-java-dashboard/frontend/package.json @@ -9,7 +9,8 @@ "lint": "eslint .", "format": "prettier --write src", "format:check": "prettier --check src", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test" }, "dependencies": { "diff2html": "^3.4.56", @@ -32,6 +33,7 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.0.1", - "prettier": "^3.5.3" + "prettier": "^3.5.3", + "@playwright/test": "^1.49.0" } } diff --git a/git-proxy-java-dashboard/frontend/playwright.config.ts b/git-proxy-java-dashboard/frontend/playwright.config.ts new file mode 100644 index 00000000..6e6417cd --- /dev/null +++ b/git-proxy-java-dashboard/frontend/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + use: { + baseURL: 'http://localhost:8080', + trace: 'on-first-retry', + }, + webServer: { + command: 'GITPROXY_DATABASE_TYPE=h2-mem ./gradlew :git-proxy-java-dashboard:run', + cwd: '../../', + url: 'http://localhost:8080/api/health', + timeout: 120_000, + reuseExistingServer: !process.env.CI, + }, + projects: [ + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/.auth/admin.json', + }, + dependencies: ['setup'], + }, + ], +}) diff --git a/git-proxy-java-dashboard/frontend/src/api.ts b/git-proxy-java-dashboard/frontend/src/api.ts index 1c3e5bd4..0f2bfc48 100644 --- a/git-proxy-java-dashboard/frontend/src/api.ts +++ b/git-proxy-java-dashboard/frontend/src/api.ts @@ -237,7 +237,7 @@ export async function fetchUserPermissions(username: string) { export async function addUserPermission( username: string, - data: { provider: string; path: string; pathType: string; operations: string }, + data: { provider: string; value: string; matchType: string; operations: string }, ) { const res = await apiFetch(`/api/users/${encodeURIComponent(username)}/permissions`, { method: 'POST', diff --git a/git-proxy-java-dashboard/frontend/src/components/PermissionBadges.tsx b/git-proxy-java-dashboard/frontend/src/components/PermissionBadges.tsx index 9040a139..300e7591 100644 --- a/git-proxy-java-dashboard/frontend/src/components/PermissionBadges.tsx +++ b/git-proxy-java-dashboard/frontend/src/components/PermissionBadges.tsx @@ -1,6 +1,6 @@ import type { RepoPermission } from '../types' -export function PathTypeBadge({ pathType }: { pathType: RepoPermission['pathType'] }) { +export function PathTypeBadge({ matchType }: { matchType: RepoPermission['matchType'] }) { const styles = { LITERAL: 'bg-gray-100 text-gray-600', GLOB: 'bg-purple-50 text-purple-700', @@ -8,9 +8,9 @@ export function PathTypeBadge({ pathType }: { pathType: RepoPermission['pathType } return ( - {pathType.toLowerCase()} + {matchType.toLowerCase()} ) } diff --git a/git-proxy-java-dashboard/frontend/src/pages/Profile.tsx b/git-proxy-java-dashboard/frontend/src/pages/Profile.tsx index 16f94355..af8db53f 100644 --- a/git-proxy-java-dashboard/frontend/src/pages/Profile.tsx +++ b/git-proxy-java-dashboard/frontend/src/pages/Profile.tsx @@ -275,9 +275,9 @@ export function Profile() { - + - {p.path} + {p.value} diff --git a/git-proxy-java-dashboard/frontend/src/pages/PushDetail.tsx b/git-proxy-java-dashboard/frontend/src/pages/PushDetail.tsx index 1d7f045b..294323eb 100644 --- a/git-proxy-java-dashboard/frontend/src/pages/PushDetail.tsx +++ b/git-proxy-java-dashboard/frontend/src/pages/PushDetail.tsx @@ -100,10 +100,6 @@ function formatTime(ts: string | number | undefined) { } } -function stripAnsi(str: string) { - // eslint-disable-next-line no-control-regex - return (str ?? '').replace(/\x1b\[[0-9;]*m/g, '') -} /** * Build a direct link to the commit on the upstream SCM. @@ -804,15 +800,18 @@ export function PushDetail({ currentUser }: PushDetailProps) { {s.errorMessage ?? s.blockedMessage ?? (isSkipped ? 'skipped' : '')} - {(isFailed || isSkipped) && s.content && ( - - {isOpen ? '▲ hide' : '▼ details'} - - )} + {(isFailed || isSkipped) && + (s.content || s.errorMessage || s.blockedMessage) && ( + + {isOpen ? '▲ hide' : '▼ details'} + + )} - {isOpen && s.content && ( + {isOpen && (s.content || s.errorMessage || s.blockedMessage) && (
-                          {stripAnsi(s.content)}
+                          {[s.errorMessage ?? s.blockedMessage, s.content]
+                            .filter(Boolean)
+                            .join('\n\n')}
                         
)} diff --git a/git-proxy-java-dashboard/frontend/src/pages/UserDetail.tsx b/git-proxy-java-dashboard/frontend/src/pages/UserDetail.tsx index e7dabd4e..f8103479 100644 --- a/git-proxy-java-dashboard/frontend/src/pages/UserDetail.tsx +++ b/git-proxy-java-dashboard/frontend/src/pages/UserDetail.tsx @@ -626,8 +626,8 @@ function AddPermissionModal({ try { await addUserPermission(username, { provider: provider.trim(), - path: path.trim(), - pathType, + value: path.trim(), + matchType: pathType, operations, }) onAdded() @@ -790,9 +790,9 @@ function PermissionsTab({ username, isAdmin }: { username: string; isAdmin: bool - + - {p.path} + {p.value} diff --git a/git-proxy-java-dashboard/frontend/src/types.ts b/git-proxy-java-dashboard/frontend/src/types.ts index 6ce9abee..b1deaa31 100644 --- a/git-proxy-java-dashboard/frontend/src/types.ts +++ b/git-proxy-java-dashboard/frontend/src/types.ts @@ -128,8 +128,8 @@ export interface RepoPermission { id: string username: string provider: string - path: string - pathType: 'LITERAL' | 'GLOB' | 'REGEX' + value: string + matchType: 'LITERAL' | 'GLOB' | 'REGEX' operations: 'PUSH' | 'REVIEW' | 'PUSH_AND_REVIEW' | 'SELF_CERTIFY' source: 'CONFIG' | 'DB' } diff --git a/git-proxy-java-dashboard/frontend/tests/auth.setup.ts b/git-proxy-java-dashboard/frontend/tests/auth.setup.ts new file mode 100644 index 00000000..e0855e36 --- /dev/null +++ b/git-proxy-java-dashboard/frontend/tests/auth.setup.ts @@ -0,0 +1,17 @@ +import { test as setup } from '@playwright/test' +import path from 'path' +import fs from 'fs' + +const authFile = path.join(import.meta.dirname, '.auth/admin.json') + +setup('authenticate as admin', async ({ page }) => { + fs.mkdirSync(path.dirname(authFile), { recursive: true }) + + await page.goto('/login.html') + await page.fill('#username', 'admin') + await page.fill('#password', 'admin') + await page.click('button[type="submit"]') + await page.waitForURL('**/dashboard/**') + + await page.context().storageState({ path: authFile }) +}) diff --git a/git-proxy-java-dashboard/frontend/tests/emails.spec.ts b/git-proxy-java-dashboard/frontend/tests/emails.spec.ts new file mode 100644 index 00000000..694b64e5 --- /dev/null +++ b/git-proxy-java-dashboard/frontend/tests/emails.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test' + +test('add and remove an email on the user detail page', async ({ page }) => { + const testEmail = `playwright-${Date.now()}@example.com` + + await page.goto('/dashboard/users/admin') + + // Overview tab is default — scope actions to the Email Addresses section + const emailSection = page.locator('section').filter({ has: page.getByText('Email Addresses') }) + + await emailSection.getByRole('button', { name: '+ Add' }).click() + await expect(page.getByRole('heading', { name: 'Add Email' })).toBeVisible() + await page.getByPlaceholder('user@example.com').fill(testEmail) + await page.getByRole('button', { name: 'Add', exact: true }).click() + + // New email should appear in the list + await expect(emailSection.getByText(testEmail)).toBeVisible() + + // Remove it and verify it disappears + const row = emailSection.locator('li').filter({ hasText: testEmail }) + await row.getByRole('button', { name: 'Remove' }).click() + await expect(emailSection.getByText(testEmail)).not.toBeVisible() +}) diff --git a/git-proxy-java-dashboard/frontend/tests/permissions.spec.ts b/git-proxy-java-dashboard/frontend/tests/permissions.spec.ts new file mode 100644 index 00000000..aaadf006 --- /dev/null +++ b/git-proxy-java-dashboard/frontend/tests/permissions.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test' + +test('add and remove a permission from the user detail page', async ({ page }) => { + const testPath = `/playwright-test/${Date.now()}/**` + + await page.goto('/dashboard/users/admin') + + // Switch to the Permissions tab + await page.getByRole('button', { name: 'Permissions' }).click() + await expect(page.getByRole('button', { name: '+ Add Permission' })).toBeVisible() + + // Open the modal and fill in the path; provider/matchType/operations use their defaults + await page.getByRole('button', { name: '+ Add Permission' }).click() + await expect(page.getByRole('heading', { name: 'Add Permission' })).toBeVisible() + await page.getByPlaceholder('/owner/repo').fill(testPath) + await page.getByRole('button', { name: 'Add', exact: true }).click() + + // New row should appear in the table with the correct badges + const row = page.locator('tbody tr').filter({ hasText: testPath }) + await expect(row).toBeVisible() + await expect(row.getByText('glob')).toBeVisible() + await expect(row.getByText('push_and_review')).toBeVisible() + + // Remove the permission and verify it disappears + await row.getByRole('button', { name: 'Remove' }).click() + await expect(page.getByText(testPath)).not.toBeVisible() +}) diff --git a/git-proxy-java-dashboard/frontend/tests/scm-identities.spec.ts b/git-proxy-java-dashboard/frontend/tests/scm-identities.spec.ts new file mode 100644 index 00000000..aef83906 --- /dev/null +++ b/git-proxy-java-dashboard/frontend/tests/scm-identities.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test' + +test('add and remove an SCM identity on the user detail page', async ({ page }) => { + const testUsername = `playwright-test-${Date.now()}` + + await page.goto('/dashboard/users/admin') + + // Overview tab is default — scope actions to the SCM Identities section + const scmSection = page.locator('section').filter({ has: page.getByText('SCM Identities') }) + + await scmSection.getByRole('button', { name: '+ Add' }).click() + await expect(page.getByRole('heading', { name: 'Add SCM Identity' })).toBeVisible() + // Provider select auto-populates with the first configured provider + await page.getByPlaceholder('github-handle').fill(testUsername) + await page.getByRole('button', { name: 'Add', exact: true }).click() + + // New identity should appear in the list + await expect(scmSection.getByText(testUsername)).toBeVisible() + + // Remove it and verify it disappears + const row = scmSection.locator('li').filter({ hasText: testUsername }) + await row.getByRole('button', { name: 'Remove' }).click() + await expect(scmSection.getByText(testUsername)).not.toBeVisible() +})