diff --git a/.github/workflows/pr-e2e.yml b/.github/workflows/pr-e2e.yml new file mode 100644 index 00000000..f25d8072 --- /dev/null +++ b/.github/workflows/pr-e2e.yml @@ -0,0 +1,89 @@ +name: PR E2E Tests + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - 'web/**' + - 'server/**' + - 'scripts/**' + - 'docker-compose.yml' + - 'docker-compose.staging.yml' + - 'Makefile' + - '.github/workflows/pr-e2e.yml' + workflow_dispatch: + +concurrency: + group: pr-e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + e2e-tests: + name: E2E (Real Services) + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: maven + + - name: Ensure Maven wrapper is executable + run: chmod +x server/mvnw + + - name: Start full dev stack + run: make dev-all + + - name: Install Playwright browsers + run: cd web && pnpm exec playwright install --with-deps chromium + + - name: Run frontend E2E tests + run: make test-e2e-frontend + + - name: Upload Playwright HTML report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: web/playwright-report/ + if-no-files-found: warn + retention-days: 30 + + - name: Upload Playwright raw test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results + path: web/test-results/ + if-no-files-found: warn + retention-days: 14 + + - name: Stop full dev stack + if: ${{ always() }} + run: make dev-all-down diff --git a/Makefile b/Makefile index f03a2938..70a61cd4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset dev-logs dev-status build test check clean web-deps web-install web-install-ci dev-server dev-server-restart dev-web build-backend test-backend build-frontend test-frontend build-web test-web typecheck-web lint-web generate-api db-reset namespace-smoke validate-release-config staging staging-down staging-logs pr parallel-init parallel-sync parallel-up parallel-down +.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset dev-logs dev-status build test check clean web-deps web-install web-install-ci dev-server dev-server-restart dev-web build-backend test-backend build-frontend test-frontend test-e2e-frontend test-e2e-smoke-frontend build-web test-web typecheck-web lint-web generate-api db-reset namespace-smoke validate-release-config staging staging-down staging-logs pr parallel-init parallel-sync parallel-up parallel-down DEV_DIR := .dev DEV_SERVER_PID := $(DEV_DIR)/server.pid @@ -25,7 +25,7 @@ STAGING_BASE_COMPOSE := docker compose -p $(STAGING_COMPOSE_PROJECT_NAME) STAGING_COMPOSE := $(STAGING_BASE_COMPOSE) -f docker-compose.yml -f docker-compose.staging.yml help: ## 显示帮助 - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' dev: ## 启动本地开发环境(依赖服务,含 skill-scanner) @@ -234,6 +234,12 @@ build-frontend: web-deps ## 构建前端 test-frontend: web-deps ## 运行前端单元测试 cd web && pnpm run test +test-e2e-frontend: web-deps ## 运行前端 E2E 测试(Playwright) + cd web && pnpm run test:e2e + +test-e2e-smoke-frontend: web-deps ## 运行前端 E2E smoke 测试(Playwright) + cd web && pnpm run test:e2e:smoke + build-web: build-frontend ## 构建前端 test-web: test-frontend ## 运行前端测试 diff --git a/docs/e2e.md b/docs/e2e.md new file mode 100644 index 00000000..4b807278 --- /dev/null +++ b/docs/e2e.md @@ -0,0 +1,159 @@ +# SkillHub Web E2E 测试说明(真实请求版) + +本文档描述当前 `web/e2e` 的真实请求(non-mock)测试体系、执行方式与维护规范。 + +## 1. 当前状态 + +`web/e2e` 已完成 API mock 迁移,现状如下: + +- 不再使用 `helpers/route-mocks.ts`、`helpers/api-fixtures.ts`、`helpers/assertions.ts` +- 不在 spec 内使用 `page.route('**/api/...')` 拦截 API +- 通过 Playwright `request`(`page.context().request`)与后端进行真实认证与数据交互 +- 关键会话 helper:`web/e2e/helpers/session.ts` + +当前 Playwright 配置(`web/playwright.config.ts`): + +- `baseURL`: `http://localhost:3000` +- 浏览器:`chromium` +- `workers`: `1`(真实请求模式下优先稳定性) +- `fullyParallel`: `false` +- `reporter`: `html` +- `trace: 'on-first-retry'` +- `screenshot: 'on'` +- `webServer.command`: `pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort` + +## 2. 目录结构 + +```text +web/ +├── e2e/ +│ ├── auth-entry.spec.ts +│ ├── dashboard-shell.spec.ts +│ ├── landing-navigation.spec.ts +│ ├── public-pages.spec.ts +│ ├── route-guard.spec.ts +│ ├── settings-pages.spec.ts +│ ├── tokens.spec.ts +│ └── helpers/ +│ ├── auth-fixtures.ts +│ ├── session.ts +│ └── test-data-builder.ts +├── playwright.config.ts +└── playwright.smoke.config.ts +``` + +职责约定: + +- `web/e2e/*.spec.ts`:按用户业务流组织测试 +- `web/e2e/helpers/auth-fixtures.ts`:locale 等非网络辅助 +- `web/e2e/helpers/session.ts`:真实认证会话建立(登录/注册 + worker 级隔离) +- `web/e2e/helpers/test-data-builder.ts`:通用测试数据构建与清理(namespace/skill/review) + +## 3. 当前覆盖范围 + +当前真实请求 E2E 覆盖 23 个 spec: + +- `auth-entry.spec.ts`:登录入口、注册入口、`returnTo` 保留 +- `landing-navigation.spec.ts`:首页导航与匿名受限跳转 +- `public-pages.spec.ts`:公开法律页面可达 +- `search-flow.spec.ts`:搜索查询状态与匿名收藏筛选跳转登录 +- `route-guard.spec.ts`:匿名拦截与登录后访问受保护路由 +- `skill-detail-browse.spec.ts`:登录后命名空间/技能详情不存在场景 +- `dashboard-shell.spec.ts`:Dashboard 基础壳层与快捷入口 +- `dashboard-routes.spec.ts`:Dashboard 主要子路由可达与命名空间治理页面可达 +- `workspace-pages.spec.ts`:我的技能/我的命名空间工作台页面可达 +- `my-namespaces-data.spec.ts`:通过 request 创建 namespace 并在工作台验证可见 +- `my-skills-data.spec.ts`:通过 request 发布 skill 并在工作台验证可见 +- `my-skills-navigation.spec.ts`:从我的技能列表进入技能详情并返回 +- `namespace-members-data.spec.ts`:通过 request 准备 namespace 后验证成员管理页可达 +- `namespace-page-data.spec.ts`:通过 request 准备 namespace/skill 后验证命名空间公开页可达 +- `namespace-reviews-data.spec.ts`:通过 request 造 review 数据并验证命名空间审核页可达 +- `publish-flow-ui.spec.ts`:在发布页上传真实 zip 并验证发布后回到我的技能 +- `dashboard-personal-modules.spec.ts`:`/dashboard/stars` 与 `/dashboard/notifications` 个人模块可达 +- `settings-pages.spec.ts`:Profile/Security/Notifications 页面基础行为 +- `settings-routing.spec.ts`:`/settings/accounts` 重定向到 `/settings/security` +- `tokens.spec.ts`:Token 管理入口可达 +- `protected-routes.spec.ts`:匿名访问 dashboard/admin 受保护路由跳转登录 +- `cli-auth.spec.ts`:CLI Auth 缺失参数错误路径 +- `role-access-control.spec.ts`:登录普通用户访问治理/管理台受限路由会被回退 + +## 4. 执行命令 + +推荐优先使用根目录命令: + +```bash +make test-e2e-frontend +make test-e2e-smoke-frontend +``` + +在 `web` 目录也可直接执行: + +```bash +cd web && pnpm test:e2e +cd web && pnpm test:e2e:smoke +cd web && pnpm exec playwright test e2e/.spec.ts +cd web && pnpm test:e2e:ui +``` + +说明: + +- 在你已手动启动服务时,可直接执行 `cd web && pnpm test:e2e` +- 在 CI 或独立环境中,可让 Playwright 根据配置自动拉起前端服务 + +## 5. 编写规范(真实请求) + +### 5.1 严禁 API mock + +新增或修改 E2E 时,禁止: + +- 引入 `page.route('**/api/...')` +- 引入页面级 API mock helper +- 在用例中伪造关键业务响应 + +### 5.2 认证统一走 `session.ts` + +- 需要登录态的用例统一复用 `registerSession(page, testInfo)` +- 通过 worker 级账号隔离避免并发冲突 +- 不在 spec 内重复手写登录/注册流程 + +### 5.3 选择器优先级 + +- `getByRole` +- `getByLabel` +- `getByTestId` + +避免结构耦合高的 CSS 深层选择器。 + +### 5.4 禁止盲等 + +不要新增 `waitForTimeout`。优先: + +- `await expect(locator).toBeVisible()` +- `await expect(page).toHaveURL(...)` +- `await expect(locator).toContainText(...)` + +## 6. Smoke 规则 + +Smoke 只保留关键路径,目标是快且稳,不追求覆盖面最大。 + +当前 smoke 套件建议包含: + +- `auth-entry.spec.ts` +- `landing-navigation.spec.ts` +- `route-guard.spec.ts` +- `dashboard-shell.spec.ts` + +## 7. 常见问题排查 + +- 认证失败:先确认后端可达(`http://localhost:8080`)且注册/登录接口正常 +- 用例偶发失败:优先检查选择器歧义和断言时机,不要用固定等待掩盖 +- 并发冲突:确认用例是否复用统一 helper,并避免共享可变测试数据 + +## 8. 验收口径 + +满足以下条件视为迁移完成: + +- `web/e2e/**/*.spec.ts` 不含 API mock +- 真实请求路径可达并稳定 +- `cd web && pnpm test:e2e` 全量通过 +- `cd web && pnpm test:e2e:smoke` 通过 diff --git a/web/e2e/auth-entry.spec.ts b/web/e2e/auth-entry.spec.ts new file mode 100644 index 00000000..aeeec995 --- /dev/null +++ b/web/e2e/auth-entry.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +test.describe('Auth Entry (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('validates required fields and preserves returnTo on register link', async ({ page }) => { + await page.goto('/login?returnTo=%2Fdashboard%2Ftokens') + + await expect(page.getByRole('heading', { name: 'Login to SkillHub' })).toBeVisible() + + await page.getByRole('button', { name: 'Login' }).click() + await expect(page.getByText('Username is required')).toBeVisible() + await expect(page.getByText('Password is required')).toBeVisible() + + await page.getByRole('link', { name: 'Sign up now' }).click() + await expect(page).toHaveURL('/register?returnTo=%2Fdashboard%2Ftokens') + }) +}) diff --git a/web/e2e/cli-auth.spec.ts b/web/e2e/cli-auth.spec.ts new file mode 100644 index 00000000..bdf21320 --- /dev/null +++ b/web/e2e/cli-auth.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +test.describe('CLI Auth (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('shows error for missing redirect params', async ({ page }) => { + await page.goto('/cli/auth') + await expect(page.getByRole('heading', { name: 'Authorization failed' })).toBeVisible() + }) +}) diff --git a/web/e2e/dashboard-personal-modules.spec.ts b/web/e2e/dashboard-personal-modules.spec.ts new file mode 100644 index 00000000..16e7b3c6 --- /dev/null +++ b/web/e2e/dashboard-personal-modules.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' + +test.describe('Dashboard Personal Modules (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('opens stars page', async ({ page }) => { + await page.goto('/dashboard/stars') + await expect(page.getByRole('heading', { name: 'My Stars' })).toBeVisible() + }) + + test('opens notifications page', async ({ page }) => { + await page.goto('/dashboard/notifications') + await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible() + }) +}) diff --git a/web/e2e/dashboard-routes.spec.ts b/web/e2e/dashboard-routes.spec.ts new file mode 100644 index 00000000..57604d96 --- /dev/null +++ b/web/e2e/dashboard-routes.spec.ts @@ -0,0 +1,48 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' + +async function expectRouteOpenOrRedirected(page: Page, route: string, heading: string) { + await page.goto(route) + + if (page.url().includes(route)) { + await expect(page.getByRole('heading', { name: heading }).first()).toBeVisible() + return + } + + await expect.poll(() => page.url()).not.toContain(route) +} + +test.describe('Dashboard Routes (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('opens major dashboard pages', async ({ page }) => { + await page.goto('/dashboard/skills') + await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible() + + await page.goto('/dashboard/publish') + await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible() + + await page.goto('/dashboard/namespaces') + await expect(page.getByRole('heading', { name: 'My Namespaces' })).toBeVisible() + + await page.goto('/dashboard/stars') + await expect(page.getByRole('heading', { name: 'My Stars' })).toBeVisible() + + await page.goto('/dashboard/notifications') + await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible() + }) + + test('opens governance and namespace management pages', async ({ page }) => { + await expectRouteOpenOrRedirected(page, '/dashboard/governance', 'Governance Center') + + await page.goto('/dashboard/namespaces/e2e-missing-namespace/members') + await expect(page.getByRole('heading', { name: 'Namespace not found' })).toBeVisible() + + await page.goto('/dashboard/namespaces/e2e-missing-namespace/reviews') + await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() + }) +}) diff --git a/web/e2e/dashboard-shell.spec.ts b/web/e2e/dashboard-shell.spec.ts new file mode 100644 index 00000000..edf16833 --- /dev/null +++ b/web/e2e/dashboard-shell.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' + +test.describe('Dashboard Shell (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('renders account summary and quick links', async ({ page }) => { + await page.goto('/dashboard') + + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() + await expect(page.getByText('Account Information')).toBeVisible() + await expect(page.getByRole('link', { name: 'View API Tokens' })).toBeVisible() + await expect(page.getByRole('link', { name: 'View My Skills' }).first()).toBeVisible() + }) +}) diff --git a/web/e2e/fixtures/sample-skill.zip b/web/e2e/fixtures/sample-skill.zip new file mode 100644 index 00000000..7640df6c --- /dev/null +++ b/web/e2e/fixtures/sample-skill.zip @@ -0,0 +1 @@ +fake zip payload for playwright diff --git a/web/e2e/helpers/api-mocks.ts b/web/e2e/helpers/api-mocks.ts deleted file mode 100644 index 2547d414..00000000 --- a/web/e2e/helpers/api-mocks.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { Page, Route } from '@playwright/test' - -type EnvelopeOptions = { - status?: number - msg?: string -} - -type SkillSummary = { - id: number - slug: string - displayName: string - summary?: string - downloadCount: number - starCount: number - ratingCount: number - namespace: string - updatedAt: string - canSubmitPromotion: boolean - headlineVersion?: { - id: number - version: string - status: string - } - publishedVersion?: { - id: number - version: string - status: string - } -} - -type SearchResponse = { - items: SkillSummary[] - total: number - page: number - size: number -} - -type SearchHandler = (url: URL) => SearchResponse -type SkillDetailHandler = () => SkillSummary - -const JSON_HEADERS = { - 'access-control-allow-origin': '*', - 'content-type': 'application/json', -} - -function envelope(data: T, options: EnvelopeOptions = {}) { - return JSON.stringify({ - code: options.status && options.status >= 400 ? options.status : 0, - msg: options.msg ?? 'ok', - data, - timestamp: '2026-03-27T00:00:00Z', - requestId: 'playwright-e2e', - }) -} - -async function fulfillJson(route: Route, data: T, options?: EnvelopeOptions) { - await route.fulfill({ - status: options?.status ?? 200, - headers: JSON_HEADERS, - body: envelope(data, options), - }) -} - -export function skill( - id: number, - displayName: string, - overrides: Partial = {}, -): SkillSummary { - return { - id, - slug: displayName.toLowerCase().replace(/\s+/g, '-'), - displayName, - summary: `${displayName} summary`, - downloadCount: 100 + id, - starCount: 10 + id, - ratingCount: 0, - namespace: 'global', - updatedAt: '2026-03-20T00:00:00Z', - canSubmitPromotion: false, - headlineVersion: { - id: id * 10, - version: '1.0.0', - status: 'PUBLISHED', - }, - publishedVersion: { - id: id * 10, - version: '1.0.0', - status: 'PUBLISHED', - }, - ...overrides, - } -} - -export async function setEnglishLocale(page: Page) { - await page.addInitScript(() => { - window.localStorage.setItem('i18nextLng', 'en') - }) -} - -export async function mockStaticApis( - page: Page, - options: { - authenticated?: boolean - }, -) { - const authenticated = options.authenticated ?? false - - await page.route('**/api/v1/auth/me', async (route) => { - if (!authenticated) { - await fulfillJson(route, null, { status: 401, msg: 'Unauthorized' }) - return - } - - await fulfillJson(route, { - userId: 'local-user', - displayName: 'Local User', - platformRoles: [], - }) - }) - - await page.route('**/api/v1/auth/methods**', async (route) => { - await fulfillJson(route, []) - }) - - await page.route('**/api/v1/auth/providers**', async (route) => { - await fulfillJson(route, []) - }) - - await page.route('**/api/web/labels', async (route) => { - await fulfillJson(route, [ - { slug: 'official', type: 'RECOMMENDED', displayName: 'Official' }, - { slug: 'featured', type: 'RECOMMENDED', displayName: 'Featured' }, - ]) - }) -} - -export async function mockCommonApis( - page: Page, - options: { - authenticated?: boolean - searchHandler?: SearchHandler - skillDetailHandler?: SkillDetailHandler - }, -) { - await mockStaticApis(page, options) - - if (options.searchHandler) { - await page.route('**/api/web/skills?**', async (route) => { - const url = new URL(route.request().url()) - await fulfillJson(route, options.searchHandler!(url)) - }) - } - - if (options.skillDetailHandler) { - await page.route('**/api/web/skills/**', async (route) => { - // Skip search endpoint - if (route.request().url().includes('?')) { - return route.continue() - } - await fulfillJson(route, options.skillDetailHandler!()) - }) - } -} diff --git a/web/e2e/helpers/auth-fixtures.ts b/web/e2e/helpers/auth-fixtures.ts new file mode 100644 index 00000000..31a9fb4a --- /dev/null +++ b/web/e2e/helpers/auth-fixtures.ts @@ -0,0 +1,7 @@ +import type { Page } from '@playwright/test' + +export async function setEnglishLocale(page: Page) { + await page.addInitScript(() => { + window.localStorage.setItem('i18nextLng', 'en') + }) +} diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts new file mode 100644 index 00000000..da6cca00 --- /dev/null +++ b/web/e2e/helpers/session.ts @@ -0,0 +1,145 @@ +import { expect, type Page, type TestInfo } from '@playwright/test' + +const password = 'Passw0rd!123' +const cachedUserByWorker = new Map() + +function usernameForWorker(testInfo?: TestInfo): string { + const worker = testInfo?.parallelIndex ?? 0 + return `e2e_worker_${worker}` +} + +function uniqueUsernameForWorker(testInfo?: TestInfo): string { + const worker = testInfo?.parallelIndex ?? 0 + const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}` + return `e2e_w${worker}_${suffix}` +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500 +} + +async function loginWithRetry( + request: Page['request'], + username: string, + retries = process.env.CI ? 10 : 6, +): Promise { + for (let i = 0; i < retries; i += 1) { + try { + const login = await request.post('/api/v1/auth/local/login', { + data: { username, password }, + }) + + if (login.ok()) { + return true + } + + const status = login.status() + if (!isRetryableStatus(status)) { + return false + } + } catch { + // Request context can be transiently unstable in CI startup windows. + } + + await sleep(250 * (i + 1)) + } + + return false +} + +async function registerSessionOnce(page: Page, testInfo?: TestInfo) { + const worker = testInfo?.parallelIndex ?? 0 + const cached = cachedUserByWorker.get(worker) + const username = usernameForWorker(testInfo) + const request = page.context().request + + // Prime auth provider endpoint to stabilize cookie/bootstrap behavior. + try { + await request.get('/api/v1/auth/providers') + } catch { + // Best effort warm-up. + } + + // Prefer the known-good cached account to avoid repeated failed-logins on a fixed username. + if (cached && await loginWithRetry(request, cached)) { + return { username: cached, password } + } + + // Support environments where a deterministic worker account already exists. + if (!cached && await loginWithRetry(request, username, process.env.CI ? 4 : 3)) { + cachedUserByWorker.set(worker, username) + return { username, password } + } + + // Registering creates session cookies for the current request context. + // Prefer creating a new unique account to avoid password drift and login throttling. + for (let i = 0; i < 12; i += 1) { + const uniqueUsername = `${uniqueUsernameForWorker(testInfo)}_${i}` + + try { + const register = await request.post('/api/v1/auth/local/register', { + data: { + username: uniqueUsername, + password, + email: `${uniqueUsername}@example.test`, + }, + }) + + if (register.ok()) { + cachedUserByWorker.set(worker, uniqueUsername) + return { username: uniqueUsername, password } + } + + const status = register.status() + if (status === 409) { + continue + } + + if (isRetryableStatus(status)) { + await sleep(300 * (i + 1)) + continue + } + + // Username invalidation/conflicts can happen under concurrent CI retries. + if (status === 400 || status === 409) { + continue + } + + expect(register.ok()).toBeTruthy() + } catch { + await sleep(300 * (i + 1)) + } + } + + // Final fallback for environments where registration is temporarily unavailable. + const fallbackCandidates = [cached, username].filter((candidate): candidate is string => Boolean(candidate)) + for (const candidate of fallbackCandidates) { + if (await loginWithRetry(request, candidate, process.env.CI ? 12 : 8)) { + cachedUserByWorker.set(worker, candidate) + return { username: candidate, password } + } + } + + throw new Error(`Failed to establish e2e session for worker ${worker}`) +} + +export async function registerSession(page: Page, testInfo?: TestInfo) { + let lastError: unknown + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await registerSessionOnce(page, testInfo) + } catch (error) { + lastError = error + if (attempt < 2) { + await sleep(500 * (attempt + 1)) + } + } + } + + throw lastError +} diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts new file mode 100644 index 00000000..1a18e5e7 --- /dev/null +++ b/web/e2e/helpers/test-data-builder.ts @@ -0,0 +1,284 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { execFileSync } from 'node:child_process' +import path from 'node:path' +import type { APIRequestContext, Page, TestInfo } from '@playwright/test' + +type CleanupTask = () => Promise + +export interface SeededNamespace { + id: number + slug: string + displayName: string +} + +export interface SeededSkill { + skillId: number + namespace: string + slug: string + version: string + status: string +} + +export interface SeededReviewData { + namespace: SeededNamespace + skill: SeededSkill +} + +interface ApiEnvelope { + code: number + msg: string + data: T +} + +interface ApiFailure extends Error { + status?: number + code?: number +} + +function asApiErrorBody(value: unknown): string { + if (!value || typeof value !== 'object') { + return '' + } + const maybe = value as { msg?: unknown } + return typeof maybe.msg === 'string' ? maybe.msg : '' +} + +function uniqueSuffix(testInfo?: TestInfo): string { + const worker = testInfo?.parallelIndex ?? 0 + return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` +} + +function buildSkillPackageZipBuffer(suffix: string): Buffer { + const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-')) + try { + const packageDir = path.join(tempRoot, `pkg-${suffix}`) + const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`) + const skillName = `e2e-skill-${suffix}`.slice(0, 48) + const skillMd = `--- +name: ${skillName} +description: E2E generated skill for real-request tests +version: 1.0.0 +--- + +# ${skillName} + +Generated by Playwright E2E. +` + + execFileSync('mkdir', ['-p', packageDir]) + writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') + writeFileSync(path.join(packageDir, 'README.md'), `# ${skillName}\n`, 'utf8') + execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) + return readFileSync(zipPath) + } finally { + rmSync(tempRoot, { recursive: true, force: true }) + } +} + +function createSkillPackageZipFile(suffix: string): { filePath: string; cleanup: () => void } { + const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-file-')) + const packageDir = path.join(tempRoot, `pkg-${suffix}`) + const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`) + const skillName = `e2e-skill-${suffix}`.slice(0, 48) + const skillMd = `--- +name: ${skillName} +description: E2E generated skill for real-request tests +version: 1.0.0 +--- + +# ${skillName} + +Generated by Playwright E2E. +` + + execFileSync('mkdir', ['-p', packageDir]) + writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8') + writeFileSync(path.join(packageDir, 'README.md'), `# ${skillName}\n`, 'utf8') + execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir }) + + return { + filePath: zipPath, + cleanup: () => { + rmSync(tempRoot, { recursive: true, force: true }) + }, + } +} + +async function parseEnvelope(response: Awaited>): Promise { + const text = await response.text() + let parsed: ApiEnvelope | null = null + try { + parsed = JSON.parse(text) as ApiEnvelope + } catch { + throw new Error(`Non-JSON response: status=${response.status()} body=${text.slice(0, 200)}`) + } + + if (!response.ok() || parsed.code !== 0) { + const error = new Error( + `API failed: status=${response.status()} code=${parsed.code} msg=${asApiErrorBody(parsed) || parsed.msg}`, + ) as ApiFailure + error.status = response.status() + error.code = parsed.code + throw error + } + return parsed.data +} + +export class E2eTestDataBuilder { + private readonly request: APIRequestContext + private readonly suffix: string + private readonly cleanupTasks: CleanupTask[] = [] + private ensuredNamespace?: SeededNamespace + + constructor( + private readonly page: Page, + testInfo?: TestInfo, + ) { + this.request = page.context().request + this.suffix = uniqueSuffix(testInfo) + } + + async init(): Promise { + // Prime CSRF/session cookie path used by write endpoints. + await this.request.get('/api/v1/auth/providers') + } + + async cleanup(): Promise { + for (let i = this.cleanupTasks.length - 1; i >= 0; i -= 1) { + try { + await this.cleanupTasks[i]() + } catch { + // Best-effort cleanup for E2E environments. + } + } + } + + async createNamespace(base = 'e2e-team'): Promise { + const slug = `${base}-${this.suffix}`.slice(0, 64) + const displayName = `E2E ${slug}` + + const created = await parseEnvelope( + await this.request.post('/api/v1/namespaces', { + data: { + slug, + displayName, + description: `E2E namespace ${slug}`, + }, + }), + ) + + this.cleanupTasks.push(async () => { + await this.request.post(`/api/web/namespaces/${encodeURIComponent(created.slug)}/archive`, { + data: { reason: 'e2e cleanup' }, + }) + }) + + return created + } + + async listMyNamespaces(): Promise { + return parseEnvelope( + await this.request.get('/api/web/me/namespaces'), + ) + } + + async ensureWritableNamespace(): Promise { + if (this.ensuredNamespace) { + return this.ensuredNamespace + } + + try { + const created = await this.createNamespace('e2e-team') + this.ensuredNamespace = created + return created + } catch (error) { + const failure = error as ApiFailure + if (failure.status !== 403) { + throw error + } + } + + const namespaces = await this.listMyNamespaces() + const writable = namespaces.find((item) => item.slug !== 'global') ?? namespaces[0] + if (!writable) { + throw new Error('No namespace available for e2e data seeding') + } + this.ensuredNamespace = writable + return writable + } + + private async getMySkillInNamespace(namespaceSlug: string): Promise { + const page = await parseEnvelope<{ + items: Array<{ + id: number + namespace: string + slug: string + headlineVersion?: { version: string; status: string } + }> + }>( + await this.request.get('/api/web/me/skills?page=0&size=50'), + ) + + const hit = page.items.find((item) => item.namespace === namespaceSlug) + if (!hit || !hit.headlineVersion) { + return null + } + + return { + skillId: hit.id, + namespace: hit.namespace, + slug: hit.slug, + version: hit.headlineVersion.version, + status: hit.headlineVersion.status, + } + } + + async publishSkill(namespaceSlug: string): Promise { + const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` + const zipBuffer = buildSkillPackageZipBuffer(unique) + + let result: SeededSkill + try { + result = await parseEnvelope( + await this.request.post(`/api/web/skills/${encodeURIComponent(namespaceSlug)}/publish`, { + multipart: { + file: { + name: 'sample-skill.zip', + mimeType: 'application/zip', + buffer: zipBuffer, + }, + visibility: 'PUBLIC', + }, + }), + ) + } catch (error) { + const fallback = await this.getMySkillInNamespace(namespaceSlug) + if (!fallback) { + throw error + } + return fallback + } + + this.cleanupTasks.push(async () => { + await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`) + }) + + return result + } + + createSkillPackageFile(): string { + const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` + const { filePath, cleanup } = createSkillPackageZipFile(unique) + this.cleanupTasks.push(async () => { + cleanup() + }) + return filePath + } + + async createReviewData(): Promise { + const namespace = await this.ensureWritableNamespace() + const skill = await this.publishSkill(namespace.slug) + return { namespace, skill } + } +} diff --git a/web/e2e/landing-navigation.spec.ts b/web/e2e/landing-navigation.spec.ts new file mode 100644 index 00000000..f3368b29 --- /dev/null +++ b/web/e2e/landing-navigation.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +test.describe('Landing Navigation (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('submits the hero search to the search page', async ({ page }) => { + await page.goto('/') + + await expect(page.getByRole('heading', { name: 'Discover & Share AI Skills' })).toBeVisible() + + const searchInput = page.getByPlaceholder('Search skills...') + await searchInput.fill('agent ops') + await searchInput.press('Enter') + + await expect(page).toHaveURL(/\/search\?q=agent(\+|%20)ops&sort=relevance&page=0&starredOnly=false$/) + }) + + test('redirects anonymous publish attempts to login', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'Publish Skill' }).click() + await expect(page).toHaveURL(/\/login\?returnTo=%2Fdashboard%2Fpublish$/) + }) +}) diff --git a/web/e2e/my-namespaces-data.spec.ts b/web/e2e/my-namespaces-data.spec.ts new file mode 100644 index 00000000..af2d4b64 --- /dev/null +++ b/web/e2e/my-namespaces-data.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('My Namespaces Data (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('shows namespace created by request helper', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + await page.goto('/dashboard/namespaces') + + await expect(page.getByRole('heading', { name: 'My Namespaces' })).toBeVisible() + await expect(page.getByText(`@${namespace.slug}`)).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/e2e/my-skills-data.spec.ts b/web/e2e/my-skills-data.spec.ts new file mode 100644 index 00000000..96393ae6 --- /dev/null +++ b/web/e2e/my-skills-data.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('My Skills Data (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('shows request-published skill in dashboard list', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const skill = await builder.publishSkill(namespace.slug) + + await page.goto('/dashboard/skills') + + await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible() + await expect(page.getByText(`@${skill.namespace}`).first()).toBeVisible() + await expect(page.getByText(`v${skill.version}`).first()).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/e2e/my-skills-navigation.spec.ts b/web/e2e/my-skills-navigation.spec.ts new file mode 100644 index 00000000..0bba27b8 --- /dev/null +++ b/web/e2e/my-skills-navigation.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('My Skills Navigation (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('opens seeded skill detail from dashboard list and returns back', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const skill = await builder.publishSkill(namespace.slug) + + await page.goto('/dashboard/skills') + await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible() + + await page.getByText(`@${skill.namespace}`).first().click() + await expect(page).toHaveURL(new RegExp(`/space/${skill.namespace}/${skill.slug}\\?returnTo=%2Fdashboard%2Fskills$`)) + + await page.getByRole('button', { name: 'Back' }).click() + await expect(page).toHaveURL('/dashboard/skills') + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/e2e/namespace-members-data.spec.ts b/web/e2e/namespace-members-data.spec.ts new file mode 100644 index 00000000..c3ac91c7 --- /dev/null +++ b/web/e2e/namespace-members-data.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('Namespace Members Data (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('opens members management for writable namespace', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + + await page.goto(`/dashboard/namespaces/${namespace.slug}/members`) + + await expect(page.getByRole('heading', { name: 'Member Management' })).toBeVisible() + await expect(page.getByText(`@${namespace.slug}`).first()).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/e2e/namespace-page-data.spec.ts b/web/e2e/namespace-page-data.spec.ts new file mode 100644 index 00000000..9acdec40 --- /dev/null +++ b/web/e2e/namespace-page-data.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('Namespace Page Data (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('shows namespace page with request-seeded skill context', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + await builder.publishSkill(namespace.slug) + + await page.goto(`/space/${namespace.slug}`) + + await expect(page.getByText(`@${namespace.slug}`).first()).toBeVisible() + await expect(page.getByRole('heading', { name: 'Skills', exact: true })).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/e2e/namespace-reviews-data.spec.ts b/web/e2e/namespace-reviews-data.spec.ts new file mode 100644 index 00000000..a173ecaa --- /dev/null +++ b/web/e2e/namespace-reviews-data.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('Namespace Reviews Data (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('opens namespace reviews page with seeded review data context', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const seeded = await builder.createReviewData() + await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) + + await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() + await expect(page.getByText(`Review tasks for ${seeded.namespace.displayName}`)).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/e2e/network-error.spec.ts b/web/e2e/network-error.spec.ts deleted file mode 100644 index 348ab3ae..00000000 --- a/web/e2e/network-error.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { expect, test } from '@playwright/test' -import { mockStaticApis, setEnglishLocale, skill } from './helpers/api-mocks' - -function buildSearchResponse(url: URL) { - const q = url.searchParams.get('q') ?? '' - const page = Number(url.searchParams.get('page') ?? '0') - const size = Number(url.searchParams.get('size') ?? '12') - - if (q === 'skill') { - return { - items: [skill(2, 'Recovered Skill Search')], - total: 1, - page, - size, - } - } - - return { - items: [skill(1, 'Initial Search Result')], - total: 1, - page, - size, - } -} - -test.describe('Network Error Handling', () => { - test.beforeEach(async ({ page }) => { - await setEnglishLocale(page) - await mockStaticApis(page, { authenticated: false }) - }) - - test('shows an empty state when a search request fails', async ({ page }) => { - let failSearchRequests = false - - await page.route('**/api/web/skills?**', async (route) => { - if (failSearchRequests) { - await route.abort('internetdisconnected') - return - } - - const url = new URL(route.request().url()) - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - code: 0, - msg: 'ok', - data: buildSearchResponse(url), - timestamp: '2026-03-27T00:00:00Z', - requestId: 'playwright-e2e', - }), - }) - }) - - await page.goto('/search?q=&sort=relevance&page=0&starredOnly=false') - await expect(page.getByRole('heading', { name: /^Initial Search Result$/ })).toBeVisible() - - failSearchRequests = true - - const searchInput = page.getByRole('textbox') - await searchInput.fill('test query') - await searchInput.press('Enter') - - await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible() - await expect(searchInput).toHaveValue('test query') - }) - - test('renders the page shell even when the initial request fails', async ({ page }) => { - await page.route('**/api/web/skills?**', async (route) => { - await route.abort('internetdisconnected') - }) - - await page.goto('/search?q=&sort=relevance&page=0&starredOnly=false') - - await expect(page.getByRole('textbox')).toBeVisible() - await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible() - }) - - test('recovers when a later search request succeeds again', async ({ page }) => { - let failSearchRequests = false - - await page.route('**/api/web/skills?**', async (route) => { - if (failSearchRequests) { - await route.abort('internetdisconnected') - return - } - - const url = new URL(route.request().url()) - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - code: 0, - msg: 'ok', - data: buildSearchResponse(url), - timestamp: '2026-03-27T00:00:00Z', - requestId: 'playwright-e2e', - }), - }) - }) - - await page.goto('/search?q=&sort=relevance&page=0&starredOnly=false') - await expect(page.getByRole('heading', { name: /^Initial Search Result$/ })).toBeVisible() - - const searchInput = page.getByRole('textbox') - - failSearchRequests = true - await searchInput.fill('offline query') - await searchInput.press('Enter') - await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible() - - failSearchRequests = false - await searchInput.fill('skill') - await searchInput.press('Enter') - - await expect(page.getByRole('heading', { name: /^Recovered Skill Search$/ })).toBeVisible() - await expect(page.getByRole('heading', { name: 'No results found' })).not.toBeVisible() - }) -}) diff --git a/web/e2e/protected-routes.spec.ts b/web/e2e/protected-routes.spec.ts new file mode 100644 index 00000000..6a85b0a9 --- /dev/null +++ b/web/e2e/protected-routes.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +test.describe('Protected Routes (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('redirects anonymous users from dashboard and admin routes', async ({ page }) => { + await page.goto('/dashboard/publish') + await expect(page).toHaveURL(/\/login\?returnTo=%2Fdashboard%2Fpublish$/) + + await page.goto('/dashboard/governance') + await expect(page).toHaveURL(/\/login\?returnTo=%2Fdashboard%2Fgovernance$/) + + await page.goto('/dashboard/reviews') + await expect(page).toHaveURL(/\/login\?returnTo=%2Fdashboard%2Freviews$/) + + await page.goto('/admin/users') + await expect(page).toHaveURL(/\/login\?returnTo=%2Fadmin%2Fusers$/) + }) +}) diff --git a/web/e2e/public-pages.spec.ts b/web/e2e/public-pages.spec.ts new file mode 100644 index 00000000..f29942c5 --- /dev/null +++ b/web/e2e/public-pages.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +test.describe('Public Legal Pages (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('renders privacy and terms documents directly', async ({ page }) => { + await page.goto('/privacy') + await expect(page.getByRole('heading', { name: 'Privacy Policy' })).toBeVisible() + + await page.goto('/terms') + await expect(page.getByRole('heading', { name: 'Terms of Service' })).toBeVisible() + }) +}) diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts new file mode 100644 index 00000000..a1f1ef76 --- /dev/null +++ b/web/e2e/publish-flow-ui.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +test.describe('Publish Flow UI (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('publishes a generated skill package from dashboard page', async ({ page }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const packagePath = builder.createSkillPackageFile() + + await page.goto('/dashboard/publish') + await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible() + + await page.locator('#namespace').click() + await page.getByText(new RegExp(`\\(@${namespace.slug}\\)`)).first().click() + + await page.locator('input[type="file"]').setInputFiles(packagePath) + await page.getByRole('button', { name: 'Confirm Publish' }).click() + + await expect(page).toHaveURL('/dashboard/skills') + await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible() + } finally { + await builder.cleanup() + } + }) +}) diff --git a/web/e2e/role-access-control.spec.ts b/web/e2e/role-access-control.spec.ts new file mode 100644 index 00000000..61a79aeb --- /dev/null +++ b/web/e2e/role-access-control.spec.ts @@ -0,0 +1,30 @@ +import { expect, test, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' + +async function expectLeaveForbiddenRoute(page: Page, route: string) { + await page.goto(route) + await expect.poll(() => page.url()).not.toBe(`http://localhost:3000${route}`) +} + +test.describe('Role Access Control (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('redirects regular user from review center', async ({ page }) => { + await expectLeaveForbiddenRoute(page, '/dashboard/reviews') + }) + + test('redirects regular user from promotions and reports pages', async ({ page }) => { + await expectLeaveForbiddenRoute(page, '/dashboard/promotions') + await expectLeaveForbiddenRoute(page, '/dashboard/reports') + }) + + test('redirects regular user from admin pages', async ({ page }) => { + await expectLeaveForbiddenRoute(page, '/admin/users') + await expectLeaveForbiddenRoute(page, '/admin/audit-log') + await expectLeaveForbiddenRoute(page, '/admin/labels') + }) +}) diff --git a/web/e2e/route-guard.spec.ts b/web/e2e/route-guard.spec.ts new file mode 100644 index 00000000..0a6d7254 --- /dev/null +++ b/web/e2e/route-guard.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' + +test.describe('Route Guards (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('redirects anonymous users to login for protected routes', async ({ page }) => { + await page.goto('/dashboard') + await expect(page).toHaveURL(/\/login\?returnTo=%2Fdashboard$/) + + await page.goto('/space/team-alpha') + await expect(page).toHaveURL(/\/login\?returnTo=%2Fspace%2Fteam-alpha$/) + }) + + test('allows authenticated users to open dashboard', async ({ page }, testInfo) => { + await registerSession(page, testInfo) + + await page.goto('/dashboard') + await expect(page).toHaveURL('/dashboard') + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() + }) +}) diff --git a/web/e2e/search-flow.spec.ts b/web/e2e/search-flow.spec.ts index 13e0f506..3dfeda1c 100644 --- a/web/e2e/search-flow.spec.ts +++ b/web/e2e/search-flow.spec.ts @@ -1,114 +1,25 @@ import { expect, test } from '@playwright/test' -import { mockCommonApis, setEnglishLocale, skill } from './helpers/api-mocks' +import { setEnglishLocale } from './helpers/auth-fixtures' -function buildSearchResponse(url: URL) { - const q = url.searchParams.get('q') ?? '' - const sort = url.searchParams.get('sort') ?? 'newest' - const label = url.searchParams.get('label') ?? '' - const page = Number(url.searchParams.get('page') ?? '0') - const size = Number(url.searchParams.get('size') ?? '12') - - if (q === 'agent' && sort === 'downloads' && label === 'official' && page === 1) { - return { - items: [skill(4, 'Official Agent Page Two')], - total: 24, - page, - size, - } - } - - if (q === 'agent' && sort === 'downloads' && label === 'official') { - return { - items: [skill(3, 'Official Agent')], - total: 24, - page, - size, - } - } - - if (q === 'agent' && sort === 'downloads') { - return { - items: [skill(2, 'Download Leader Agent')], - total: 1, - page, - size, - } - } - - if (q === 'agent') { - return { - items: [skill(1, 'Agent Builder')], - total: 1, - page, - size, - } - } - - return { - items: [skill(10, 'Alpha Search'), skill(11, 'Beta Search')], - total: 2, - page, - size, - } -} - -test.describe('Search Page Flows', () => { +test.describe('Search Flow (Real API)', () => { test.beforeEach(async ({ page }) => { await setEnglishLocale(page) - await mockCommonApis(page, { - searchHandler: buildSearchResponse, - }) }) - test('updates URL state and results when searching, sorting, and filtering', async ({ page }) => { - await page.goto('/search?q=&sort=relevance&page=0&starredOnly=false') - - await expect(page.getByRole('heading', { name: /^Alpha Search$/ })).toBeVisible() - await expect(page.getByRole('heading', { name: /^Beta Search$/ })).toBeVisible() - - const searchInput = page.getByRole('textbox') - await searchInput.fill('agent') - await searchInput.press('Enter') - - await expect(page).toHaveURL(/\/search\?q=agent&sort=relevance&page=0&starredOnly=false$/) - await expect(page.getByRole('heading', { name: /^Agent Builder$/ })).toBeVisible() - await expect(page.getByRole('heading', { name: /^Alpha Search$/ })).not.toBeVisible() - - await page.getByRole('button', { name: 'Downloads' }).click() - - await expect(page).toHaveURL(/\/search\?q=agent&sort=downloads&page=0&starredOnly=false$/) - await expect(page.getByRole('heading', { name: /^Download Leader Agent$/ })).toBeVisible() - await expect(page.getByRole('heading', { name: /^Agent Builder$/ })).not.toBeVisible() - - await page.getByRole('button', { name: 'Official' }).click() - - await expect(page).toHaveURL(/\/search\?q=agent&label=official&sort=downloads&page=0&starredOnly=false$/) - await expect(page.getByRole('heading', { name: /^Official Agent$/ })).toBeVisible() - await expect(page.getByRole('heading', { name: /^Download Leader Agent$/ })).not.toBeVisible() - }) - - test('keeps the active label when paginating', async ({ page }) => { - await page.goto('/search?q=agent&label=official&sort=downloads&page=0&starredOnly=false') - - await expect(page.getByRole('heading', { name: /^Official Agent$/ })).toBeVisible() - - await page.getByRole('button', { name: 'Next' }).click() - - await expect(page).toHaveURL(/\/search\?q=agent&label=official&sort=downloads&page=1&starredOnly=false$/) - await expect(page.getByRole('heading', { name: /^Official Agent Page Two$/ })).toBeVisible() - await expect(page.getByRole('heading', { name: /^Official Agent$/ })).not.toBeVisible() - }) - - test('redirects unauthenticated users to login when enabling starred-only', async ({ page }) => { + test('renders search controls and keeps query state in URL', async ({ page }) => { await page.goto('/search?q=agent&sort=downloads&page=0&starredOnly=false') + await expect(page).toHaveURL('/search?q=agent&sort=downloads&page=0&starredOnly=false') + await expect(page.getByRole('button', { name: 'Relevance' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Downloads' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Newest' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Starred only' })).toBeVisible() + }) + + test('redirects anonymous user to login when enabling starred filter', async ({ page }) => { + await page.goto('/search?q=agent&sort=relevance&page=0&starredOnly=false') + await page.getByRole('button', { name: 'Starred only' }).click() - - await expect(page).toHaveURL(/\/login\?returnTo=/) - - const currentUrl = new URL(page.url()) - expect(currentUrl.pathname).toBe('/login') - expect(currentUrl.searchParams.get('returnTo')).toBe('/search?q=agent&sort=downloads&page=0&starredOnly=false') - await expect(page.getByRole('heading', { name: 'Login to SkillHub' })).toBeVisible() + await expect(page).toHaveURL(/\/login\?returnTo=%2Fsearch%3Fq%3Dagent%26sort%3Drelevance%26page%3D0%26starredOnly%3Dfalse$/) }) }) diff --git a/web/e2e/settings-pages.spec.ts b/web/e2e/settings-pages.spec.ts new file mode 100644 index 00000000..8c77919c --- /dev/null +++ b/web/e2e/settings-pages.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' + +test.describe('Settings Pages (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('opens profile settings page', async ({ page }) => { + await page.goto('/settings/profile') + await expect(page.getByRole('heading', { name: 'Profile Settings' })).toBeVisible() + }) + + test('shows validation when current password is missing', async ({ page }) => { + await page.goto('/settings/security') + await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() + + await page.getByRole('button', { name: 'Update Password' }).click() + await expect(page.getByText('Please enter your current password')).toBeVisible() + }) + + test('opens notification settings page', async ({ page }) => { + await page.goto('/settings/notifications') + await expect(page.getByRole('heading', { name: 'Notification Settings' })).toBeVisible() + }) +}) diff --git a/web/e2e/settings-routing.spec.ts b/web/e2e/settings-routing.spec.ts new file mode 100644 index 00000000..e5ed15a7 --- /dev/null +++ b/web/e2e/settings-routing.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' + +test.describe('Settings Routing (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('redirects accounts route to security settings', async ({ page }) => { + await page.goto('/settings/accounts') + await expect(page).toHaveURL('/settings/security') + await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() + }) +}) diff --git a/web/e2e/share-button.spec.ts b/web/e2e/share-button.spec.ts deleted file mode 100644 index 5cbf364d..00000000 --- a/web/e2e/share-button.spec.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { expect, test } from '@playwright/test' -import { mockStaticApis, setEnglishLocale, skill } from './helpers/api-mocks' - -test.describe('Skill Share Button', () => { - test.beforeEach(async ({ page, context }) => { - await setEnglishLocale(page) - - // Grant clipboard permissions - await context.grantPermissions(['clipboard-read', 'clipboard-write']) - - await mockStaticApis(page, { authenticated: true }) - - // Mock skill sub-resource APIs (versions, files, etc.) to prevent server errors - await page.route('**/api/web/skills/*/versions', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - code: 0, msg: 'ok', - data: { items: [{ id: 10, version: '1.0.0', status: 'PUBLISHED', createdAt: '2026-03-20T00:00:00Z' }], total: 1, page: 0, size: 20 }, - timestamp: '2026-03-28T00:00:00Z', requestId: 'playwright-e2e', - }), - }) - }) - - await page.route('**/api/web/skills/*/versions/*/files', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ code: 0, msg: 'ok', data: [], timestamp: '2026-03-28T00:00:00Z', requestId: 'playwright-e2e' }), - }) - }) - - await page.route('**/api/web/skills/*/versions/*', async (route) => { - // Let the versions list route handle its own path - if (route.request().url().endsWith('/versions')) return route.continue() - if (route.request().url().includes('/files')) return route.continue() - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - code: 0, msg: 'ok', - data: { id: 10, version: '1.0.0', status: 'PUBLISHED', createdAt: '2026-03-20T00:00:00Z' }, - timestamp: '2026-03-28T00:00:00Z', requestId: 'playwright-e2e', - }), - }) - }) - }) - - test('copies share text to clipboard when share button is clicked', async ({ page }) => { - // Mock skill detail API - await page.route('**/api/web/skills/global/test-skill', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - code: 0, - msg: 'ok', - data: skill(1, 'Test Skill', { - summary: 'A useful test skill for sharing', - namespace: 'global', - slug: 'test-skill', - }), - timestamp: '2026-03-28T00:00:00Z', - requestId: 'playwright-e2e', - }), - }) - }) - - await page.goto('/space/global/test-skill') - - // Wait for skill detail page to load - await expect(page.getByRole('heading', { name: /^Test Skill$/ })).toBeVisible() - - // Find and click the share button - const shareButton = page.getByRole('button', { name: /Share/i }) - await expect(shareButton).toBeVisible() - await shareButton.click() - - // Verify button shows "Copied" state - await expect(page.getByRole('button', { name: /Copied/i })).toBeVisible() - - // Verify clipboard content - const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) - expect(clipboardText).toContain('test-skill') - expect(clipboardText).toContain('http://localhost:3000/space/global/test-skill') - expect(clipboardText.split('\n')).toHaveLength(2) - }) - - test('share text includes skill description when available', async ({ page }) => { - await page.route('**/api/web/skills/global/test-skill', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - code: 0, - msg: 'ok', - data: skill(1, 'Test Skill', { - summary: 'A useful test skill for sharing', - namespace: 'global', - slug: 'test-skill', - }), - timestamp: '2026-03-28T00:00:00Z', - requestId: 'playwright-e2e', - }), - }) - }) - - await page.goto('/space/global/test-skill') - - await expect(page.getByRole('heading', { name: /^Test Skill$/ })).toBeVisible() - - const shareButton = page.getByRole('button', { name: /Share/i }) - await shareButton.click() - - const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) - // Description is truncated to fit within 30 char limit (displayName + " - " + desc) - expect(clipboardText).toContain('A useful test sk') - }) - - test('share button resets to normal state after 2 seconds', async ({ page }) => { - await page.route('**/api/web/skills/global/test-skill', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - code: 0, - msg: 'ok', - data: skill(1, 'Test Skill', { - summary: 'A useful test skill', - namespace: 'global', - slug: 'test-skill', - }), - timestamp: '2026-03-28T00:00:00Z', - requestId: 'playwright-e2e', - }), - }) - }) - - await page.goto('/space/global/test-skill') - - await expect(page.getByRole('heading', { name: /^Test Skill$/ })).toBeVisible() - - const shareButton = page.getByRole('button', { name: /Share/i }) - await shareButton.click() - - // Should show "Copied" immediately - await expect(page.getByRole('button', { name: /Copied/i })).toBeVisible() - - // Should reset to "Share" after 2 seconds - await page.waitForTimeout(2100) - await expect(page.getByRole('button', { name: /^Share$/i })).toBeVisible() - }) - - test('formats namespaced skill correctly in share text', async ({ page, context }) => { - await context.grantPermissions(['clipboard-read', 'clipboard-write']) - - await page.route('**/api/web/skills/team-alpha/namespaced-skill', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - code: 0, - msg: 'ok', - data: skill(2, 'Namespaced Skill', { - summary: 'Team skill', - namespace: 'team-alpha', - slug: 'namespaced-skill', - }), - timestamp: '2026-03-28T00:00:00Z', - requestId: 'playwright-e2e', - }), - }) - }) - - await page.goto('/space/team-alpha/namespaced-skill') - - await expect(page.getByRole('heading', { name: /^Namespaced Skill$/ })).toBeVisible() - - const shareButton = page.getByRole('button', { name: /Share/i }) - await shareButton.click() - - const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) - expect(clipboardText).toContain('team-alpha/namespaced-skill') - expect(clipboardText).toContain('http://localhost:3000/space/team-alpha/namespaced-skill') - }) -}) diff --git a/web/e2e/skill-detail-browse.spec.ts b/web/e2e/skill-detail-browse.spec.ts new file mode 100644 index 00000000..212d35bb --- /dev/null +++ b/web/e2e/skill-detail-browse.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' + +test.describe('Skill Detail Browse (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('shows not found for unknown namespace', async ({ page }) => { + await page.goto('/space/e2e-missing-namespace') + await expect(page.getByRole('heading', { name: 'Namespace not found' })).toBeVisible() + }) + + test('shows not found for unknown skill detail route', async ({ page }) => { + await page.goto('/space/e2e-missing-namespace/e2e-missing-skill') + await expect(page.getByRole('heading', { name: 'Skill not found' })).toBeVisible() + }) +}) diff --git a/web/e2e/tokens.spec.ts b/web/e2e/tokens.spec.ts new file mode 100644 index 00000000..bfa916a0 --- /dev/null +++ b/web/e2e/tokens.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' + +test.describe('Tokens Page (Real API)', () => { + test.describe.configure({ timeout: 90_000 }) + + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('renders token management page and create action', async ({ page }) => { + await page.goto('/dashboard/tokens') + + await expect(page.getByRole('heading', { name: 'Token Management' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Create Token' })).toBeVisible() + }) +}) diff --git a/web/e2e/workspace-pages.spec.ts b/web/e2e/workspace-pages.spec.ts new file mode 100644 index 00000000..a25d1d49 --- /dev/null +++ b/web/e2e/workspace-pages.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' + +test.describe('Workspace Pages (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('opens my skills and my namespaces pages', async ({ page }) => { + await page.goto('/dashboard/skills') + await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible() + + await page.goto('/dashboard/namespaces') + await expect(page.getByRole('heading', { name: 'My Namespaces' })).toBeVisible() + }) +}) diff --git a/web/package.json b/web/package.json index da847426..728f7a23 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "preview": "vite preview", "test": "vitest run", "test:e2e": "playwright test", + "test:e2e:smoke": "playwright test -c playwright.smoke.config.ts", "test:e2e:ui": "playwright test --ui", "typecheck": "tsc --noEmit", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", diff --git a/web/playwright.config.ts b/web/playwright.config.ts index f5203983..b7206587 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -3,9 +3,10 @@ import { defineConfig, devices } from '@playwright/test' export default defineConfig({ testDir: './e2e', fullyParallel: false, + timeout: process.env.CI ? 90_000 : 45_000, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: 1, + workers: process.env.CI ? 1 : 2, reporter: 'html', use: { baseURL: 'http://localhost:3000', @@ -19,7 +20,7 @@ export default defineConfig({ }, ], webServer: { - command: 'pnpm preview --port 3000', + command: 'pnpm exec vite --host 127.0.0.1 --port 3000 --strictPort', url: 'http://localhost:3000', reuseExistingServer: true, timeout: 120000, diff --git a/web/playwright.smoke.config.ts b/web/playwright.smoke.config.ts new file mode 100644 index 00000000..5476fd3e --- /dev/null +++ b/web/playwright.smoke.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test' +import baseConfig from './playwright.config' + +const smokeSpecs = [ + 'auth-entry.spec.ts', + 'landing-navigation.spec.ts', + 'route-guard.spec.ts', + 'dashboard-shell.spec.ts', +] + +export default defineConfig({ + ...baseConfig, + testMatch: smokeSpecs, +}) diff --git a/web/src/features/skill/share-button.tsx b/web/src/features/skill/share-button.tsx index d1d26044..c42c2fc5 100644 --- a/web/src/features/skill/share-button.tsx +++ b/web/src/features/skill/share-button.tsx @@ -43,6 +43,7 @@ export function ShareButton({ namespace, slug, description }: ShareButtonProps) return (