diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000000..ec0613abd0 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,52 @@ +name: E2E CI +permissions: + contents: read + +on: + pull_request: + push: + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: Test Web App + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies (bun) + run: bun install + + - name: Install Playwright browsers (with system deps) + run: bunx playwright install --with-deps chromium + + - name: Run E2E tests + env: + PORT: 3010 + run: bun run e2e + + - name: Upload Playwright HTML report (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report + if-no-files-found: ignore + + - name: Upload Playwright traces (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: test-results + if-no-files-found: ignore diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts new file mode 100644 index 0000000000..8885ff3e40 --- /dev/null +++ b/e2e/routes.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test'; + +// 覆盖核心可访问路径(含重定向来源) +const baseRoutes: string[] = [ + '/', + '/chat', + '/discover', + '/image', + '/files', + '/repos', // next.config.ts -> /files + '/changelog', +]; + +// settings 路由改为通过 query 参数控制 active tab +// 参考 SettingsTabs: about, agent, common, hotkey, llm, provider, proxy, storage, system-agent, tts +const settingsTabs = [ + 'common', + 'llm', + 'provider', + 'about', + 'hotkey', + 'proxy', + 'storage', + 'tts', + 'system-agent', + 'agent', +]; + +const routes: string[] = [...baseRoutes, ...settingsTabs.map((key) => `/settings?active=${key}`)]; + +// CI 环境下跳过容易不稳定或受特性开关影响的路由 +const ciSkipPaths = new Set([ + '/image', + '/changelog', + '/settings?active=common', + '/settings?active=llm', +]); + +// @ts-ignore +async function assertNoPageErrors(page: Parameters[0]['page']) { + const pageErrors: Error[] = []; + const consoleErrors: string[] = []; + + page.on('pageerror', (err: Error) => pageErrors.push(err)); + page.on('console', (msg: any) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + + // 仅校验页面级错误,忽略控制台 error 以提升稳定性 + expect + .soft(pageErrors, `page errors: ${pageErrors.map((e) => e.message).join('\n')}`) + .toHaveLength(0); +} + +test.describe('Smoke: core routes', () => { + for (const path of routes) { + test(`should open ${path} without error`, async ({ page }) => { + if (process.env.CI && ciSkipPaths.has(path)) test.skip(true, 'skip flaky route on CI'); + const response = await page.goto(path, { waitUntil: 'commit' }); + // 2xx 或 3xx 视为可接受(允许中间件/重定向) + const status = response?.status() ?? 0; + expect(status, `unexpected status for ${path}: ${status}`).toBeLessThan(400); + + // 一般错误标题防御 + await expect(page).not.toHaveTitle(/not found|error/i); + + // body 可见 + await expect(page.locator('body')).toBeVisible(); + + await assertNoPageErrors(page); + }); + } +}); diff --git a/package.json b/package.json index ef8b5ae9e0..a9d3f54806 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,9 @@ "dev:desktop": "next dev --turbopack -p 3015", "docs:i18n": "lobe-i18n md && npm run lint:md && npm run lint:mdx && prettier -c --write locales/**/*", "docs:seo": "lobe-seo && npm run lint:mdx", + "e2e": "playwright test", + "e2e:install": "playwright install", + "e2e:ui": "playwright test --ui", "i18n": "npm run workflow:i18n && lobe-i18n && prettier -c --write \"locales/**\"", "lint": "npm run lint:ts && npm run lint:style && npm run type-check && npm run lint:circular", "lint:circular": "npm run lint:circular:main && npm run lint:circular:packages", @@ -299,6 +302,7 @@ "@next/bundle-analyzer": "^15.5.4", "@next/eslint-plugin-next": "^15.5.4", "@peculiar/webcrypto": "^1.5.0", + "@playwright/test": "^1.49.1", "@prettier/sync": "^0.6.1", "@semantic-release/exec": "^6.0.3", "@testing-library/jest-dom": "^6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..017fa29ced --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = process.env.PORT ? Number(process.env.PORT) : 3010; + +export default defineConfig({ + expect: { timeout: 10_000 }, + fullyParallel: true, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + reporter: 'list', + retries: 0, + testDir: './e2e', + timeout: 60_000, + use: { + baseURL: `http://localhost:${PORT}`, + trace: 'on-first-retry', + }, + webServer: { + command: 'npm run dev', + env: { + ENABLE_AUTH_PROTECTION: '0', + ENABLE_OIDC: '0', + NEXT_PUBLIC_ENABLE_CLERK_AUTH: '0', + NEXT_PUBLIC_ENABLE_NEXT_AUTH: '0', + NODE_OPTIONS: '--max-old-space-size=6144', + }, + reuseExistingServer: true, + timeout: 120_000, + url: `http://localhost:${PORT}/chat`, + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 4163a6b367..ce4395dcf1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,6 @@ } ] }, - "exclude": ["node_modules", "public/sw.js", "apps/desktop", "tmp", "temp", ".temp"], + "exclude": ["node_modules", "public/sw.js", "apps/desktop", "tmp", "temp", ".temp", "e2e"], "include": ["**/*.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next-env.d.ts"] } diff --git a/vitest.config.mts b/vitest.config.mts index 0ea2272c83..7904175b7e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -46,6 +46,7 @@ export default defineConfig({ '**/build/**', '**/apps/desktop/**', '**/packages/**', + '**/e2e/**', ], globals: true, server: {