From a9a93c15ae80fcb25baa1c10e38253aee724add8 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Wed, 31 Dec 2025 02:13:32 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test:=20fix=20e2e=20tests=20for=20n?= =?UTF-8?q?ew=20product=20flow=20(#11060)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add e2e tests * fix workflow * update workflow * ๐Ÿ› fix(e2e): fix smoke tests i18n and timeout issues - Unify default port to 3006 across hooks.ts and world.ts - Reduce step timeout from 30s to 10s for faster feedback - Fix i18n matching for featured sections (support zh-CN/en-US) - Add mock framework foundation for future API mocking ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * ๐Ÿ› fix(e2e): save failure screenshots to file for CI artifacts ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * ๐Ÿ› fix(e2e): move PORT to global env for consistent access ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * ๐Ÿ› fix(e2e): set onboarding as completed for test user Skip onboarding flow by setting finishedAt in test user seed --- .github/workflows/e2e.yml | 28 ++- e2e/cucumber.config.js | 4 +- e2e/package.json | 4 + .../features/discover/detail-pages.feature | 16 +- .../features/discover/interactions.feature | 28 +-- e2e/src/features/discover/smoke.feature | 16 +- e2e/src/mocks/discover/data.ts | 212 ++++++++++++++++++ e2e/src/mocks/discover/handlers.ts | 179 +++++++++++++++ e2e/src/mocks/discover/index.ts | 7 + e2e/src/mocks/discover/types.ts | 98 ++++++++ e2e/src/mocks/index.ts | 158 +++++++++++++ e2e/src/steps/common/auth.steps.ts | 100 +++++++++ e2e/src/steps/discover/detail-pages.steps.ts | 73 +++--- e2e/src/steps/discover/interactions.steps.ts | 114 +++++----- e2e/src/steps/discover/smoke.steps.ts | 95 ++++---- e2e/src/steps/hooks.ts | 75 ++++++- e2e/src/support/seedTestUser.ts | 126 +++++++++++ e2e/src/support/webServer.ts | 72 +++++- e2e/src/support/world.ts | 22 +- 19 files changed, 1224 insertions(+), 203 deletions(-) create mode 100644 e2e/src/mocks/discover/data.ts create mode 100644 e2e/src/mocks/discover/handlers.ts create mode 100644 e2e/src/mocks/discover/index.ts create mode 100644 e2e/src/mocks/discover/types.ts create mode 100644 e2e/src/mocks/index.ts create mode 100644 e2e/src/steps/common/auth.steps.ts create mode 100644 e2e/src/support/seedTestUser.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7c04aa0408..5c33d014b7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -10,6 +10,19 @@ concurrency: group: e2e-${{ github.ref }} cancel-in-progress: true +env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + DATABASE_DRIVER: node + KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= + BETTER_AUTH_SECRET: e2e-test-secret-key-for-better-auth-32chars! + NEXT_PUBLIC_ENABLE_BETTER_AUTH: '1' + NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION: '0' + # Mock S3 env vars to prevent initialization errors + S3_ACCESS_KEY_ID: e2e-mock-access-key + S3_SECRET_ACCESS_KEY: e2e-mock-secret-key + S3_BUCKET: e2e-mock-bucket + S3_ENDPOINT: https://e2e-mock-s3.localhost + jobs: e2e: name: Test Web App @@ -25,7 +38,7 @@ jobs: ports: - 5432:5432 - timeout-minutes: 25 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v5 @@ -41,12 +54,15 @@ jobs: - name: Install Playwright browsers (with system deps) run: bunx playwright install --with-deps chromium - - name: Run E2E tests + - name: Run database migrations + run: bun run db:migrate + + - name: Build application + run: bun run build env: - PORT: 3010 - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres - DATABASE_DRIVER: node - KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s= + SKIP_LINT: '1' + + - name: Run E2E tests run: bun run e2e - name: Upload Cucumber HTML report (on failure) diff --git a/e2e/cucumber.config.js b/e2e/cucumber.config.js index 220bebfeb3..ebc9541dba 100644 --- a/e2e/cucumber.config.js +++ b/e2e/cucumber.config.js @@ -10,11 +10,11 @@ export default { formatOptions: { snippetInterface: 'async-await', }, - parallel: process.env.CI ? 1 : 4, + parallel: 1, paths: ['src/features/**/*.feature'], publishQuiet: true, require: ['src/steps/**/*.ts', 'src/support/**/*.ts'], requireModule: ['tsx/cjs'], retry: 0, - timeout: 120_000, + timeout: 30_000, }; diff --git a/e2e/package.json b/e2e/package.json index ec42a5c838..69575996a3 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -4,7 +4,9 @@ "private": true, "description": "E2E tests for LobeChat using Cucumber and Playwright", "scripts": { + "build": "cd .. && bun run build", "test": "cucumber-js --config cucumber.config.js", + "test:ci": "bun run build && bun run test", "test:discover": "cucumber-js --config cucumber.config.js src/features/discover/", "test:headed": "HEADLESS=false cucumber-js --config cucumber.config.js", "test:routes": "cucumber-js --config cucumber.config.js --tags '@routes'", @@ -14,6 +16,8 @@ "dependencies": { "@cucumber/cucumber": "^12.2.0", "@playwright/test": "^1.57.0", + "bcryptjs": "^3.0.3", + "pg": "^8.16.0", "playwright": "^1.57.0" }, "devDependencies": { diff --git a/e2e/src/features/discover/detail-pages.feature b/e2e/src/features/discover/detail-pages.feature index a1d03d4747..b34b438a89 100644 --- a/e2e/src/features/discover/detail-pages.feature +++ b/e2e/src/features/discover/detail-pages.feature @@ -11,7 +11,7 @@ Feature: Discover Detail Pages @DISCOVER-DETAIL-001 @P1 Scenario: Load assistant detail page and verify content - Given I navigate to "/discover/assistant" + Given I navigate to "/community/assistant" And I wait for the page to fully load When I click on the first assistant card Then I should be on an assistant detail page @@ -22,7 +22,7 @@ Feature: Discover Detail Pages @DISCOVER-DETAIL-002 @P1 Scenario: Navigate back from assistant detail page - Given I navigate to "/discover/assistant" + Given I navigate to "/community/assistant" And I wait for the page to fully load And I click on the first assistant card When I click the back button @@ -34,7 +34,7 @@ Feature: Discover Detail Pages @DISCOVER-DETAIL-003 @P1 Scenario: Load model detail page and verify content - Given I navigate to "/discover/model" + Given I navigate to "/community/model" And I wait for the page to fully load When I click on the first model card Then I should be on a model detail page @@ -44,7 +44,7 @@ Feature: Discover Detail Pages @DISCOVER-DETAIL-004 @P1 Scenario: Navigate back from model detail page - Given I navigate to "/discover/model" + Given I navigate to "/community/model" And I wait for the page to fully load And I click on the first model card When I click the back button @@ -56,7 +56,7 @@ Feature: Discover Detail Pages @DISCOVER-DETAIL-005 @P1 Scenario: Load provider detail page and verify content - Given I navigate to "/discover/provider" + Given I navigate to "/community/provider" And I wait for the page to fully load When I click on the first provider card Then I should be on a provider detail page @@ -66,7 +66,7 @@ Feature: Discover Detail Pages @DISCOVER-DETAIL-006 @P1 Scenario: Navigate back from provider detail page - Given I navigate to "/discover/provider" + Given I navigate to "/community/provider" And I wait for the page to fully load And I click on the first provider card When I click the back button @@ -78,7 +78,7 @@ Feature: Discover Detail Pages @DISCOVER-DETAIL-007 @P1 Scenario: Load MCP detail page and verify content - Given I navigate to "/discover/mcp" + Given I navigate to "/community/mcp" And I wait for the page to fully load When I click on the first MCP card Then I should be on an MCP detail page @@ -88,7 +88,7 @@ Feature: Discover Detail Pages @DISCOVER-DETAIL-008 @P1 Scenario: Navigate back from MCP detail page - Given I navigate to "/discover/mcp" + Given I navigate to "/community/mcp" And I wait for the page to fully load And I click on the first MCP card When I click the back button diff --git a/e2e/src/features/discover/interactions.feature b/e2e/src/features/discover/interactions.feature index 875bb44a5e..7548cb3243 100644 --- a/e2e/src/features/discover/interactions.feature +++ b/e2e/src/features/discover/interactions.feature @@ -11,14 +11,14 @@ Feature: Discover Interactions @DISCOVER-INTERACT-001 @P1 Scenario: Search for assistants - Given I navigate to "/discover/assistant" + Given I navigate to "/community/assistant" When I type "developer" in the search bar And I wait for the search results to load Then I should see filtered assistant cards @DISCOVER-INTERACT-002 @P1 Scenario: Filter assistants by category - Given I navigate to "/discover/assistant" + Given I navigate to "/community/assistant" When I click on a category in the category menu And I wait for the filtered results to load Then I should see assistant cards filtered by the selected category @@ -26,7 +26,7 @@ Feature: Discover Interactions @DISCOVER-INTERACT-003 @P1 Scenario: Navigate to next page of assistants - Given I navigate to "/discover/assistant" + Given I navigate to "/community/assistant" When I click the next page button And I wait for the next page to load Then I should see different assistant cards @@ -34,7 +34,7 @@ Feature: Discover Interactions @DISCOVER-INTERACT-004 @P1 Scenario: Navigate to assistant detail page - Given I navigate to "/discover/assistant" + Given I navigate to "/community/assistant" When I click on the first assistant card Then I should be navigated to the assistant detail page And I should see the assistant detail content @@ -45,7 +45,7 @@ Feature: Discover Interactions @DISCOVER-INTERACT-005 @P1 Scenario: Sort models - Given I navigate to "/discover/model" + Given I navigate to "/community/model" When I click on the sort dropdown And I select a sort option And I wait for the sorted results to load @@ -53,7 +53,7 @@ Feature: Discover Interactions @DISCOVER-INTERACT-006 @P1 Scenario: Navigate to model detail page - Given I navigate to "/discover/model" + Given I navigate to "/community/model" When I click on the first model card Then I should be navigated to the model detail page And I should see the model detail content @@ -64,7 +64,7 @@ Feature: Discover Interactions @DISCOVER-INTERACT-007 @P1 Scenario: Navigate to provider detail page - Given I navigate to "/discover/provider" + Given I navigate to "/community/provider" When I click on the first provider card Then I should be navigated to the provider detail page And I should see the provider detail content @@ -75,14 +75,14 @@ Feature: Discover Interactions @DISCOVER-INTERACT-008 @P1 Scenario: Filter MCP tools by category - Given I navigate to "/discover/mcp" + Given I navigate to "/community/mcp" When I click on a category in the category filter And I wait for the filtered results to load Then I should see MCP cards filtered by the selected category @DISCOVER-INTERACT-009 @P1 Scenario: Navigate to MCP detail page - Given I navigate to "/discover/mcp" + Given I navigate to "/community/mcp" When I click on the first MCP card Then I should be navigated to the MCP detail page And I should see the MCP detail content @@ -93,21 +93,21 @@ Feature: Discover Interactions @DISCOVER-INTERACT-010 @P1 Scenario: Navigate from home to assistant list - Given I navigate to "/discover" + Given I navigate to "/community" When I click on the "more" link in the featured assistants section - Then I should be navigated to "/discover/assistant" + Then I should be navigated to "/community/assistant" And I should see the page body @DISCOVER-INTERACT-011 @P1 Scenario: Navigate from home to MCP list - Given I navigate to "/discover" + Given I navigate to "/community" When I click on the "more" link in the featured MCP tools section - Then I should be navigated to "/discover/mcp" + Then I should be navigated to "/community/mcp" And I should see the page body @DISCOVER-INTERACT-012 @P1 Scenario: Click featured assistant from home - Given I navigate to "/discover" + Given I navigate to "/community" When I click on the first featured assistant card Then I should be navigated to the assistant detail page And I should see the assistant detail content diff --git a/e2e/src/features/discover/smoke.feature b/e2e/src/features/discover/smoke.feature index cef3248d84..633aeed8d7 100644 --- a/e2e/src/features/discover/smoke.feature +++ b/e2e/src/features/discover/smoke.feature @@ -1,10 +1,10 @@ @discover @smoke -Feature: Discover Smoke Tests - Critical path tests to ensure the discover module is functional +Feature: Community Smoke Tests + Critical path tests to ensure the community/discover module is functional @DISCOVER-SMOKE-001 @P0 - Scenario: Load Discover Home Page - Given I navigate to "/discover" + Scenario: Load Community Home Page + Given I navigate to "/community" Then the page should load without errors And I should see the page body And I should see the featured assistants section @@ -12,7 +12,7 @@ Feature: Discover Smoke Tests @DISCOVER-SMOKE-002 @P0 Scenario: Load Assistant List Page - Given I navigate to "/discover/assistant" + Given I navigate to "/community/assistant" Then the page should load without errors And I should see the page body And I should see the search bar @@ -22,7 +22,7 @@ Feature: Discover Smoke Tests @DISCOVER-SMOKE-003 @P0 Scenario: Load Model List Page - Given I navigate to "/discover/model" + Given I navigate to "/community/model" Then the page should load without errors And I should see the page body And I should see model cards @@ -30,14 +30,14 @@ Feature: Discover Smoke Tests @DISCOVER-SMOKE-004 @P0 Scenario: Load Provider List Page - Given I navigate to "/discover/provider" + Given I navigate to "/community/provider" Then the page should load without errors And I should see the page body And I should see provider cards @DISCOVER-SMOKE-005 @P0 Scenario: Load MCP List Page - Given I navigate to "/discover/mcp" + Given I navigate to "/community/mcp" Then the page should load without errors And I should see the page body And I should see MCP cards diff --git a/e2e/src/mocks/discover/data.ts b/e2e/src/mocks/discover/data.ts new file mode 100644 index 0000000000..0f895cc607 --- /dev/null +++ b/e2e/src/mocks/discover/data.ts @@ -0,0 +1,212 @@ +/** + * Mock data for Discover/Community module + */ +import type { + AssistantListResponse, + McpListResponse, + ModelListResponse, + ProviderListResponse, +} from './types'; + +// ============================================ +// Assistant Mock Data +// ============================================ + +export const mockAssistantList: AssistantListResponse = { + items: [ + { + author: 'LobeHub', + avatar: '๐Ÿค–', + backgroundColor: '#1890ff', + category: 'general', + createdAt: '2024-01-01T00:00:00.000Z', + description: 'A versatile AI assistant for general tasks and conversations.', + identifier: 'general-assistant', + installCount: 1000, + knowledgeCount: 5, + pluginCount: 3, + title: 'General Assistant', + tokenUsage: 4096, + userName: 'lobehub', + }, + { + author: 'LobeHub', + avatar: '๐Ÿ’ป', + backgroundColor: '#52c41a', + category: 'programming', + createdAt: '2024-01-02T00:00:00.000Z', + description: 'Expert coding assistant for software development.', + identifier: 'code-assistant', + installCount: 800, + knowledgeCount: 10, + pluginCount: 5, + title: 'Code Assistant', + tokenUsage: 8192, + userName: 'lobehub', + }, + { + author: 'LobeHub', + avatar: 'โœ๏ธ', + backgroundColor: '#722ed1', + category: 'copywriting', + createdAt: '2024-01-03T00:00:00.000Z', + description: 'Professional writing assistant for content creation.', + identifier: 'writing-assistant', + installCount: 600, + knowledgeCount: 3, + pluginCount: 2, + title: 'Writing Assistant', + tokenUsage: 4096, + userName: 'lobehub', + }, + ], + pagination: { + page: 1, + pageSize: 12, + total: 3, + totalPages: 1, + }, +}; + +export const mockAssistantCategories = [ + { id: 'general', name: 'General' }, + { id: 'programming', name: 'Programming' }, + { id: 'copywriting', name: 'Copywriting' }, + { id: 'education', name: 'Education' }, +]; + +// ============================================ +// Model Mock Data +// ============================================ + +export const mockModelList: ModelListResponse = { + items: [ + { + abilities: { functionCall: true, reasoning: true, vision: true }, + contextWindowTokens: 128_000, + createdAt: '2024-01-01T00:00:00.000Z', + description: 'Most capable model for complex tasks', + displayName: 'GPT-4o', + id: 'gpt-4o', + providerId: 'openai', + providerName: 'OpenAI', + type: 'chat', + }, + { + abilities: { functionCall: true, reasoning: true, vision: false }, + contextWindowTokens: 200_000, + createdAt: '2024-01-02T00:00:00.000Z', + description: 'Advanced AI assistant by Anthropic', + displayName: 'Claude 3.5 Sonnet', + id: 'claude-3-5-sonnet-20241022', + providerId: 'anthropic', + providerName: 'Anthropic', + type: 'chat', + }, + { + abilities: { functionCall: false, reasoning: false, vision: false }, + contextWindowTokens: 32_768, + createdAt: '2024-01-03T00:00:00.000Z', + description: 'Open source language model', + displayName: 'Llama 3.1 70B', + id: 'llama-3.1-70b', + providerId: 'meta', + providerName: 'Meta', + type: 'chat', + }, + ], + pagination: { + page: 1, + pageSize: 12, + total: 3, + totalPages: 1, + }, +}; + +// ============================================ +// Provider Mock Data +// ============================================ + +export const mockProviderList: ProviderListResponse = { + items: [ + { + description: 'Leading AI research company', + id: 'openai', + logo: 'https://example.com/openai.png', + modelCount: 10, + name: 'OpenAI', + }, + { + description: 'AI safety focused research company', + id: 'anthropic', + logo: 'https://example.com/anthropic.png', + modelCount: 5, + name: 'Anthropic', + }, + { + description: 'Open source AI leader', + id: 'meta', + logo: 'https://example.com/meta.png', + modelCount: 8, + name: 'Meta', + }, + ], + pagination: { + page: 1, + pageSize: 12, + total: 3, + totalPages: 1, + }, +}; + +// ============================================ +// MCP Mock Data +// ============================================ + +export const mockMcpList: McpListResponse = { + items: [ + { + author: 'LobeHub', + avatar: '๐Ÿ”', + category: 'search', + createdAt: '2024-01-01T00:00:00.000Z', + description: 'Web search capabilities for AI assistants', + identifier: 'web-search', + installCount: 500, + title: 'Web Search', + }, + { + author: 'LobeHub', + avatar: '๐Ÿ“', + category: 'file', + createdAt: '2024-01-02T00:00:00.000Z', + description: 'File system operations and management', + identifier: 'file-manager', + installCount: 300, + title: 'File Manager', + }, + { + author: 'LobeHub', + avatar: '๐Ÿ—„๏ธ', + category: 'database', + createdAt: '2024-01-03T00:00:00.000Z', + description: 'Database query and management tools', + identifier: 'db-tools', + installCount: 200, + title: 'Database Tools', + }, + ], + pagination: { + page: 1, + pageSize: 12, + total: 3, + totalPages: 1, + }, +}; + +export const mockMcpCategories = [ + { id: 'search', name: 'Search' }, + { id: 'file', name: 'File' }, + { id: 'database', name: 'Database' }, + { id: 'utility', name: 'Utility' }, +]; diff --git a/e2e/src/mocks/discover/handlers.ts b/e2e/src/mocks/discover/handlers.ts new file mode 100644 index 0000000000..b78224d5f1 --- /dev/null +++ b/e2e/src/mocks/discover/handlers.ts @@ -0,0 +1,179 @@ +/** + * Mock handlers for Discover/Community API endpoints + */ +import type { Route } from 'playwright'; + +import { type MockHandler, createTrpcResponse } from '../index'; +import { + mockAssistantCategories, + mockAssistantList, + mockMcpCategories, + mockMcpList, + mockModelList, + mockProviderList, +} from './data'; + +// ============================================ +// Helper to parse tRPC batch requests +// ============================================ + +function parseTrpcUrl(url: string): { input?: Record; procedure: string } { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + + // Extract procedure name from path like /trpc/lambda.market.getAssistantList + const procedureMatch = pathname.match(/lambda\.market\.(\w+)/); + const procedure = procedureMatch ? procedureMatch[1] : ''; + + // Parse input from query string + let input: Record | undefined; + const inputParam = urlObj.searchParams.get('input'); + if (inputParam) { + try { + input = JSON.parse(inputParam); + } catch { + // Ignore parse errors + } + } + + return { input, procedure }; +} + +// ============================================ +// Mock Handlers +// ============================================ + +/** + * Handler for assistant list endpoint + */ +const assistantListHandler: MockHandler = { + handler: async (route: Route) => { + await route.fulfill({ + body: createTrpcResponse(mockAssistantList), + contentType: 'application/json', + status: 200, + }); + }, + pattern: '**/trpc/lambda/market.getAssistantList**', +}; + +/** + * Handler for assistant categories endpoint + */ +const assistantCategoriesHandler: MockHandler = { + handler: async (route: Route) => { + await route.fulfill({ + body: createTrpcResponse(mockAssistantCategories), + contentType: 'application/json', + status: 200, + }); + }, + pattern: '**/trpc/lambda/market.getAssistantCategories**', +}; + +/** + * Handler for model list endpoint + */ +const modelListHandler: MockHandler = { + handler: async (route: Route) => { + await route.fulfill({ + body: createTrpcResponse(mockModelList), + contentType: 'application/json', + status: 200, + }); + }, + pattern: '**/trpc/lambda/market.getModelList**', +}; + +/** + * Handler for provider list endpoint + */ +const providerListHandler: MockHandler = { + handler: async (route: Route) => { + await route.fulfill({ + body: createTrpcResponse(mockProviderList), + contentType: 'application/json', + status: 200, + }); + }, + pattern: '**/trpc/lambda/market.getProviderList**', +}; + +/** + * Handler for MCP list endpoint + */ +const mcpListHandler: MockHandler = { + handler: async (route: Route) => { + await route.fulfill({ + body: createTrpcResponse(mockMcpList), + contentType: 'application/json', + status: 200, + }); + }, + pattern: '**/trpc/lambda/market.getMcpList**', +}; + +/** + * Handler for MCP categories endpoint + */ +const mcpCategoriesHandler: MockHandler = { + handler: async (route: Route) => { + await route.fulfill({ + body: createTrpcResponse(mockMcpCategories), + contentType: 'application/json', + status: 200, + }); + }, + pattern: '**/trpc/lambda/market.getMcpCategories**', +}; + +/** + * Debug handler to log all trpc requests + */ +const trpcDebugHandler: MockHandler = { + handler: async (route: Route) => { + const url = route.request().url(); + console.log(` ๐Ÿ” TRPC Request: ${url}`); + await route.continue(); + }, + pattern: '**/trpc/**', +}; + +/** + * Fallback handler for any unhandled market endpoints + * Returns empty data to prevent hanging requests + */ +const marketFallbackHandler: MockHandler = { + handler: async (route: Route) => { + const url = route.request().url(); + const { procedure } = parseTrpcUrl(url); + + console.log(` โš ๏ธ Unhandled market endpoint: ${procedure}`); + + // Return empty response to prevent timeout + await route.fulfill({ + body: createTrpcResponse({ items: [], pagination: { page: 1, pageSize: 12, total: 0 } }), + contentType: 'application/json', + status: 200, + }); + }, + pattern: '**/trpc/lambda/market.**', +}; + +// ============================================ +// Export all handlers +// ============================================ + +export const discoverHandlers: MockHandler[] = [ + // Debug handler first to log all requests + trpcDebugHandler, + // Specific handlers (order matters - more specific first) + assistantListHandler, + assistantCategoriesHandler, + modelListHandler, + providerListHandler, + mcpListHandler, + mcpCategoriesHandler, + // Fallback handler (should be last) + marketFallbackHandler, +]; diff --git a/e2e/src/mocks/discover/index.ts b/e2e/src/mocks/discover/index.ts new file mode 100644 index 0000000000..f84e18d56c --- /dev/null +++ b/e2e/src/mocks/discover/index.ts @@ -0,0 +1,7 @@ +/** + * Discover/Community module mocks + */ + +export * from './data'; +export { discoverHandlers as discoverMocks } from './handlers'; +export * from './types'; diff --git a/e2e/src/mocks/discover/types.ts b/e2e/src/mocks/discover/types.ts new file mode 100644 index 0000000000..267a046cc1 --- /dev/null +++ b/e2e/src/mocks/discover/types.ts @@ -0,0 +1,98 @@ +/** + * Type definitions for Discover mock data + * These mirror the actual types from the application + */ + +export interface PaginationInfo { + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +// ============================================ +// Assistant Types +// ============================================ + +export interface DiscoverAssistantItem { + author: string; + avatar: string; + backgroundColor?: string; + category: string; + createdAt: string; + description: string; + identifier: string; + installCount?: number; + knowledgeCount?: number; + pluginCount?: number; + title: string; + tokenUsage?: number; + userName?: string; +} + +export interface AssistantListResponse { + items: DiscoverAssistantItem[]; + pagination: PaginationInfo; +} + +// ============================================ +// Model Types +// ============================================ + +export interface DiscoverModelItem { + abilities: { + functionCall?: boolean; + reasoning?: boolean; + vision?: boolean; + }; + contextWindowTokens: number; + createdAt: string; + description: string; + displayName: string; + id: string; + providerId: string; + providerName: string; + type: string; +} + +export interface ModelListResponse { + items: DiscoverModelItem[]; + pagination: PaginationInfo; +} + +// ============================================ +// Provider Types +// ============================================ + +export interface DiscoverProviderItem { + description: string; + id: string; + logo?: string; + modelCount: number; + name: string; +} + +export interface ProviderListResponse { + items: DiscoverProviderItem[]; + pagination: PaginationInfo; +} + +// ============================================ +// MCP Types +// ============================================ + +export interface DiscoverMcpItem { + author: string; + avatar: string; + category: string; + createdAt: string; + description: string; + identifier: string; + installCount?: number; + title: string; +} + +export interface McpListResponse { + items: DiscoverMcpItem[]; + pagination: PaginationInfo; +} diff --git a/e2e/src/mocks/index.ts b/e2e/src/mocks/index.ts new file mode 100644 index 0000000000..eae11460e3 --- /dev/null +++ b/e2e/src/mocks/index.ts @@ -0,0 +1,158 @@ +/** + * E2E Mock Framework + * + * This module provides a centralized way to mock API responses in E2E tests. + * It uses Playwright's route interception to mock tRPC and REST API calls. + */ +import type { Page, Route } from 'playwright'; + +import { discoverMocks } from './discover'; + +// ============================================ +// Types +// ============================================ + +export interface MockHandler { + /** Optional: only apply this mock when condition is true */ + enabled?: boolean; + /** Handler function to process the request */ + handler: (route: Route, request: Request) => Promise; + /** URL pattern to match (supports wildcards) */ + pattern: string | RegExp; +} + +export interface MockConfig { + /** Enable/disable all mocks globally */ + enabled: boolean; + /** Mock handlers grouped by domain */ + handlers: Record; +} + +// ============================================ +// Default Configuration +// ============================================ + +const defaultConfig: MockConfig = { + enabled: true, + handlers: { + discover: discoverMocks, + // Add more domains here as needed: + // user: userMocks, + // chat: chatMocks, + }, +}; + +// ============================================ +// Mock Manager +// ============================================ + +export class MockManager { + private config: MockConfig; + private page: Page | null = null; + + constructor(config: Partial = {}) { + this.config = { ...defaultConfig, ...config }; + } + + /** + * Setup all mock handlers for a page + */ + async setup(page: Page): Promise { + this.page = page; + + if (!this.config.enabled) { + console.log('๐Ÿ”‡ Mocks disabled'); + return; + } + + console.log('๐ŸŽญ Setting up API mocks...'); + + for (const [domain, handlers] of Object.entries(this.config.handlers)) { + for (const mock of handlers) { + if (mock.enabled === false) continue; + + await page.route(mock.pattern, async (route) => { + try { + await mock.handler(route, route.request() as unknown as Request); + } catch (error) { + console.error(`Mock handler error for ${mock.pattern}:`, error); + await route.continue(); + } + }); + } + console.log(` โœ“ ${domain} mocks registered`); + } + } + + /** + * Disable a specific mock domain + */ + disableDomain(domain: string): void { + if (this.config.handlers[domain]) { + for (const handler of this.config.handlers[domain]) { + handler.enabled = false; + } + } + } + + /** + * Enable a specific mock domain + */ + enableDomain(domain: string): void { + if (this.config.handlers[domain]) { + for (const handler of this.config.handlers[domain]) { + handler.enabled = true; + } + } + } + + /** + * Add custom mock handlers at runtime + */ + addHandlers(domain: string, handlers: MockHandler[]): void { + if (!this.config.handlers[domain]) { + this.config.handlers[domain] = []; + } + this.config.handlers[domain].push(...handlers); + } +} + +// ============================================ +// Helper Functions +// ============================================ + +/** + * Create a JSON response for tRPC endpoints + */ +export function createTrpcResponse(data: T): string { + return JSON.stringify({ + result: { + data, + }, + }); +} + +/** + * Create an error response for tRPC endpoints + */ +export function createTrpcError(message: string, code = 'INTERNAL_SERVER_ERROR'): string { + return JSON.stringify({ + error: { + code, + message, + }, + }); +} + +/** + * Create a standard JSON response + */ +export function createJsonResponse(data: T): string { + return JSON.stringify(data); +} + +// ============================================ +// Singleton Instance +// ============================================ + +export const mockManager = new MockManager(); diff --git a/e2e/src/steps/common/auth.steps.ts b/e2e/src/steps/common/auth.steps.ts new file mode 100644 index 0000000000..f2fa0b20d1 --- /dev/null +++ b/e2e/src/steps/common/auth.steps.ts @@ -0,0 +1,100 @@ +import { Given, When } from '@cucumber/cucumber'; +import { expect } from '@playwright/test'; + +import { TEST_USER, createTestSession } from '../../support/seedTestUser'; +import { CustomWorld } from '../../support/world'; + +/** + * Login via UI - fills in the login form and submits + */ +Given('I am logged in as the test user', async function (this: CustomWorld) { + // Navigate to signin page + await this.page.goto('/signin'); + + // Wait for the login form to be visible + await this.page.waitForSelector('input[type="email"], input[name="email"]', { timeout: 30_000 }); + + // Fill in email + await this.page.fill('input[type="email"], input[name="email"]', TEST_USER.email); + + // Fill in password + await this.page.fill('input[type="password"], input[name="password"]', TEST_USER.password); + + // Click submit button + await this.page.click('button[type="submit"]'); + + // Wait for navigation away from signin page + await this.page.waitForURL((url) => !url.pathname.includes('/signin'), { timeout: 30_000 }); + + console.log('โœ… Logged in as test user via UI'); +}); + +/** + * Login via session injection - faster, bypasses UI + * Creates a session directly in the database and sets the cookie + */ +Given('I am logged in with a session', async function (this: CustomWorld) { + const sessionToken = await createTestSession(); + + if (!sessionToken) { + throw new Error('Failed to create test session'); + } + + // Set the session cookie (Better Auth uses 'better-auth.session_token' by default) + await this.browserContext.addCookies([ + { + domain: 'localhost', + httpOnly: true, + name: 'better-auth.session_token', + path: '/', + sameSite: 'Lax', + secure: false, + value: sessionToken, + }, + ]); + + console.log('โœ… Session cookie set for test user'); +}); + +/** + * Navigate to signin page + */ +When('I navigate to the signin page', async function (this: CustomWorld) { + await this.page.goto('/signin'); + await this.page.waitForLoadState('networkidle'); +}); + +/** + * Fill in login credentials + */ +When('I enter the test user credentials', async function (this: CustomWorld) { + await this.page.fill('input[type="email"], input[name="email"]', TEST_USER.email); + await this.page.fill('input[type="password"], input[name="password"]', TEST_USER.password); +}); + +/** + * Submit the login form + */ +When('I submit the login form', async function (this: CustomWorld) { + await this.page.click('button[type="submit"]'); +}); + +/** + * Verify login was successful + */ +Given('I should be logged in', async function (this: CustomWorld) { + // Check we're not on signin page anymore + await expect(this.page).not.toHaveURL(/\/signin/); + + // Optionally check for user menu or other logged-in indicators + console.log('โœ… User is logged in'); +}); + +/** + * Logout the current user + */ +When('I logout', async function (this: CustomWorld) { + // Clear cookies to logout + await this.browserContext.clearCookies(); + console.log('โœ… User logged out (cookies cleared)'); +}); diff --git a/e2e/src/steps/discover/detail-pages.steps.ts b/e2e/src/steps/discover/detail-pages.steps.ts index d47874ec66..4f26056b4b 100644 --- a/e2e/src/steps/discover/detail-pages.steps.ts +++ b/e2e/src/steps/discover/detail-pages.steps.ts @@ -8,7 +8,7 @@ import { CustomWorld } from '../../support/world'; // ============================================ Given('I wait for the page to fully load', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); await this.page.waitForTimeout(1000); }); @@ -17,7 +17,7 @@ Given('I wait for the page to fully load', async function (this: CustomWorld) { // ============================================ When('I click the back button', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Try to find a back button const backButton = this.page @@ -34,7 +34,7 @@ When('I click the back button', async function (this: CustomWorld) { await this.page.goBack(); } - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); }); // ============================================ @@ -43,7 +43,7 @@ When('I click the back button', async function (this: CustomWorld) { // Assistant Detail Page Assertions Then('I should be on an assistant detail page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Check if URL matches assistant detail page pattern @@ -55,13 +55,13 @@ Then('I should be on an assistant detail page', async function (this: CustomWorl }); Then('I should see the assistant title', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Look for title element (h1, h2, or prominent text) const title = this.page .locator('h1, h2, [data-testid="detail-title"], [data-testid="assistant-title"]') .first(); - await expect(title).toBeVisible({ timeout: 120_000 }); + await expect(title).toBeVisible({ timeout: 30_000 }); // Verify title has content const titleText = await title.textContent(); @@ -69,7 +69,7 @@ Then('I should see the assistant title', async function (this: CustomWorld) { }); Then('I should see the assistant description', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Look for description element const description = this.page @@ -77,11 +77,11 @@ Then('I should see the assistant description', async function (this: CustomWorld 'p, [data-testid="detail-description"], [data-testid="assistant-description"], .description', ) .first(); - await expect(description).toBeVisible({ timeout: 120_000 }); + await expect(description).toBeVisible({ timeout: 30_000 }); }); Then('I should see the assistant author information', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Look for author information const author = this.page @@ -95,7 +95,7 @@ Then('I should see the assistant author information', async function (this: Cust }); Then('I should see the add to workspace button', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Look for add button (might be "Add", "Install", "Add to Workspace", etc.) const addButton = this.page @@ -110,18 +110,19 @@ Then('I should see the add to workspace button', async function (this: CustomWor }); Then('I should be on the assistant list page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Check if URL is assistant list (not detail page) const isListPage = - currentUrl.includes('/discover/assistant') && !/\/discover\/assistant\/[^#?]+/.test(currentUrl); + currentUrl.includes('/community/assistant') && + !/\/discover\/assistant\/[^#?]+/.test(currentUrl); expect(isListPage, `Expected URL to be assistant list page, but got: ${currentUrl}`).toBeTruthy(); }); // Model Detail Page Assertions Then('I should be on a model detail page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Check if URL matches model detail page pattern @@ -133,30 +134,30 @@ Then('I should be on a model detail page', async function (this: CustomWorld) { }); Then('I should see the model title', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const title = this.page .locator('h1, h2, [data-testid="detail-title"], [data-testid="model-title"]') .first(); - await expect(title).toBeVisible({ timeout: 120_000 }); + await expect(title).toBeVisible({ timeout: 30_000 }); const titleText = await title.textContent(); expect(titleText?.trim().length).toBeGreaterThan(0); }); Then('I should see the model description', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const description = this.page .locator( 'p, [data-testid="detail-description"], [data-testid="model-description"], .description', ) .first(); - await expect(description).toBeVisible({ timeout: 120_000 }); + await expect(description).toBeVisible({ timeout: 30_000 }); }); Then('I should see the model parameters information', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Look for parameters or specs section const params = this.page @@ -169,18 +170,18 @@ Then('I should see the model parameters information', async function (this: Cust }); Then('I should be on the model list page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Check if URL is model list (not detail page) const isListPage = - currentUrl.includes('/discover/model') && !/\/discover\/model\/[^#?]+/.test(currentUrl); + currentUrl.includes('/community/model') && !/\/discover\/model\/[^#?]+/.test(currentUrl); expect(isListPage, `Expected URL to be model list page, but got: ${currentUrl}`).toBeTruthy(); }); // Provider Detail Page Assertions Then('I should be on a provider detail page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Check if URL matches provider detail page pattern @@ -192,30 +193,30 @@ Then('I should be on a provider detail page', async function (this: CustomWorld) }); Then('I should see the provider title', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const title = this.page .locator('h1, h2, [data-testid="detail-title"], [data-testid="provider-title"]') .first(); - await expect(title).toBeVisible({ timeout: 120_000 }); + await expect(title).toBeVisible({ timeout: 30_000 }); const titleText = await title.textContent(); expect(titleText?.trim().length).toBeGreaterThan(0); }); Then('I should see the provider description', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const description = this.page .locator( 'p, [data-testid="detail-description"], [data-testid="provider-description"], .description', ) .first(); - await expect(description).toBeVisible({ timeout: 120_000 }); + await expect(description).toBeVisible({ timeout: 30_000 }); }); Then('I should see the provider website link', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Look for website link const websiteLink = this.page @@ -228,18 +229,18 @@ Then('I should see the provider website link', async function (this: CustomWorld }); Then('I should be on the provider list page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Check if URL is provider list (not detail page) const isListPage = - currentUrl.includes('/discover/provider') && !/\/discover\/provider\/[^#?]+/.test(currentUrl); + currentUrl.includes('/community/provider') && !/\/discover\/provider\/[^#?]+/.test(currentUrl); expect(isListPage, `Expected URL to be provider list page, but got: ${currentUrl}`).toBeTruthy(); }); // MCP Detail Page Assertions Then('I should be on an MCP detail page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Check if URL matches MCP detail page pattern @@ -251,28 +252,28 @@ Then('I should be on an MCP detail page', async function (this: CustomWorld) { }); Then('I should see the MCP title', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const title = this.page .locator('h1, h2, [data-testid="detail-title"], [data-testid="mcp-title"]') .first(); - await expect(title).toBeVisible({ timeout: 120_000 }); + await expect(title).toBeVisible({ timeout: 30_000 }); const titleText = await title.textContent(); expect(titleText?.trim().length).toBeGreaterThan(0); }); Then('I should see the MCP description', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const description = this.page .locator('p, [data-testid="detail-description"], [data-testid="mcp-description"], .description') .first(); - await expect(description).toBeVisible({ timeout: 120_000 }); + await expect(description).toBeVisible({ timeout: 30_000 }); }); Then('I should see the install button', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Look for install button const installButton = this.page @@ -285,11 +286,11 @@ Then('I should see the install button', async function (this: CustomWorld) { }); Then('I should be on the MCP list page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Check if URL is MCP list (not detail page) const isListPage = - currentUrl.includes('/discover/mcp') && !/\/discover\/mcp\/[^#?]+/.test(currentUrl); + currentUrl.includes('/community/mcp') && !/\/discover\/mcp\/[^#?]+/.test(currentUrl); expect(isListPage, `Expected URL to be MCP list page, but got: ${currentUrl}`).toBeTruthy(); }); diff --git a/e2e/src/steps/discover/interactions.steps.ts b/e2e/src/steps/discover/interactions.steps.ts index 94088cbd41..f8652c88cd 100644 --- a/e2e/src/steps/discover/interactions.steps.ts +++ b/e2e/src/steps/discover/interactions.steps.ts @@ -8,10 +8,10 @@ import { CustomWorld } from '../../support/world'; // ============================================ When('I type {string} in the search bar', async function (this: CustomWorld, searchText: string) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const searchBar = this.page.locator('input[type="text"]').first(); - await searchBar.waitFor({ state: 'visible', timeout: 120_000 }); + await searchBar.waitFor({ state: 'visible', timeout: 30_000 }); await searchBar.fill(searchText); // Store the search text for later assertions @@ -20,13 +20,13 @@ When('I type {string} in the search bar', async function (this: CustomWorld, sea When('I wait for the search results to load', async function (this: CustomWorld) { // Wait for network to be idle after typing - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Add a small delay to ensure UI updates await this.page.waitForTimeout(500); }); When('I click on a category in the category menu', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Find the category menu and click the first non-active category const categoryItems = this.page.locator( @@ -34,7 +34,7 @@ When('I click on a category in the category menu', async function (this: CustomW ); // Wait for categories to be visible - await categoryItems.first().waitFor({ state: 'visible', timeout: 120_000 }); + await categoryItems.first().waitFor({ state: 'visible', timeout: 30_000 }); // Click the second category (skip "All" which is usually first) const secondCategory = categoryItems.nth(1); @@ -46,7 +46,7 @@ When('I click on a category in the category menu', async function (this: CustomW }); When('I click on a category in the category filter', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Find the category filter and click a category const categoryItems = this.page.locator( @@ -54,7 +54,7 @@ When('I click on a category in the category filter', async function (this: Custo ); // Wait for categories to be visible - await categoryItems.first().waitFor({ state: 'visible', timeout: 120_000 }); + await categoryItems.first().waitFor({ state: 'visible', timeout: 30_000 }); // Click the second category (skip "All" which is usually first) const secondCategory = categoryItems.nth(1); @@ -67,35 +67,35 @@ When('I click on a category in the category filter', async function (this: Custo When('I wait for the filtered results to load', async function (this: CustomWorld) { // Wait for network to be idle after filtering - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Add a small delay to ensure UI updates await this.page.waitForTimeout(500); }); When('I click the next page button', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Find and click the next page button const nextButton = this.page.locator( 'button:has-text("Next"), button[aria-label*="next" i], .pagination button:last-child', ); - await nextButton.waitFor({ state: 'visible', timeout: 120_000 }); + await nextButton.waitFor({ state: 'visible', timeout: 30_000 }); await nextButton.click(); }); When('I wait for the next page to load', async function (this: CustomWorld) { // Wait for network to be idle after page change - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Add a small delay to ensure UI updates await this.page.waitForTimeout(500); }); When('I click on the first assistant card', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const firstCard = this.page.locator('[data-testid="assistant-item"]').first(); - await firstCard.waitFor({ state: 'visible', timeout: 120_000 }); + await firstCard.waitFor({ state: 'visible', timeout: 30_000 }); // Store the current URL before clicking this.testContext.previousUrl = this.page.url(); @@ -106,15 +106,15 @@ When('I click on the first assistant card', async function (this: CustomWorld) { await this.page.waitForFunction( (previousUrl) => window.location.href !== previousUrl, this.testContext.previousUrl, - { timeout: 120_000 }, + { timeout: 30_000 }, ); }); When('I click on the first model card', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const firstCard = this.page.locator('[data-testid="model-item"]').first(); - await firstCard.waitFor({ state: 'visible', timeout: 120_000 }); + await firstCard.waitFor({ state: 'visible', timeout: 30_000 }); // Store the current URL before clicking this.testContext.previousUrl = this.page.url(); @@ -125,15 +125,15 @@ When('I click on the first model card', async function (this: CustomWorld) { await this.page.waitForFunction( (previousUrl) => window.location.href !== previousUrl, this.testContext.previousUrl, - { timeout: 120_000 }, + { timeout: 30_000 }, ); }); When('I click on the first provider card', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const firstCard = this.page.locator('[data-testid="provider-item"]').first(); - await firstCard.waitFor({ state: 'visible', timeout: 120_000 }); + await firstCard.waitFor({ state: 'visible', timeout: 30_000 }); // Store the current URL before clicking this.testContext.previousUrl = this.page.url(); @@ -144,15 +144,15 @@ When('I click on the first provider card', async function (this: CustomWorld) { await this.page.waitForFunction( (previousUrl) => window.location.href !== previousUrl, this.testContext.previousUrl, - { timeout: 120_000 }, + { timeout: 30_000 }, ); }); When('I click on the first MCP card', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const firstCard = this.page.locator('[data-testid="mcp-item"]').first(); - await firstCard.waitFor({ state: 'visible', timeout: 120_000 }); + await firstCard.waitFor({ state: 'visible', timeout: 30_000 }); // Store the current URL before clicking this.testContext.previousUrl = this.page.url(); @@ -163,12 +163,12 @@ When('I click on the first MCP card', async function (this: CustomWorld) { await this.page.waitForFunction( (previousUrl) => window.location.href !== previousUrl, this.testContext.previousUrl, - { timeout: 120_000 }, + { timeout: 30_000 }, ); }); When('I click on the sort dropdown', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const sortDropdown = this.page .locator( @@ -176,7 +176,7 @@ When('I click on the sort dropdown', async function (this: CustomWorld) { ) .first(); - await sortDropdown.waitFor({ state: 'visible', timeout: 120_000 }); + await sortDropdown.waitFor({ state: 'visible', timeout: 30_000 }); await sortDropdown.click(); }); @@ -187,7 +187,7 @@ When('I select a sort option', async function (this: CustomWorld) { const sortOptions = this.page.locator('[role="option"], [role="menuitem"]'); // Wait for options to appear - await sortOptions.first().waitFor({ state: 'visible', timeout: 120_000 }); + await sortOptions.first().waitFor({ state: 'visible', timeout: 30_000 }); // Click the second option (skip the default/first one) const secondOption = sortOptions.nth(1); @@ -200,7 +200,7 @@ When('I select a sort option', async function (this: CustomWorld) { When('I wait for the sorted results to load', async function (this: CustomWorld) { // Wait for network to be idle after sorting - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Add a small delay to ensure UI updates await this.page.waitForTimeout(500); }); @@ -208,14 +208,14 @@ When('I wait for the sorted results to load', async function (this: CustomWorld) When( 'I click on the {string} link in the featured assistants section', async function (this: CustomWorld, linkText: string) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Find the featured assistants section and the "more" link const moreLink = this.page .locator(`a:has-text("${linkText}"), button:has-text("${linkText}")`) .first(); - await moreLink.waitFor({ state: 'visible', timeout: 120_000 }); + await moreLink.waitFor({ state: 'visible', timeout: 30_000 }); await moreLink.click(); }, ); @@ -223,7 +223,7 @@ When( When( 'I click on the {string} link in the featured MCP tools section', async function (this: CustomWorld, linkText: string) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Find the MCP section and the "more" link // Since there might be multiple "more" links, we'll click the second one (MCP is after assistants) @@ -232,7 +232,7 @@ When( ); // Wait for links to be visible - await moreLinks.first().waitFor({ state: 'visible', timeout: 120_000 }); + await moreLinks.first().waitFor({ state: 'visible', timeout: 30_000 }); // Click the second "more" link (for MCP section) await moreLinks.nth(1).click(); @@ -240,10 +240,10 @@ When( ); When('I click on the first featured assistant card', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const firstCard = this.page.locator('[data-testid="assistant-item"]').first(); - await firstCard.waitFor({ state: 'visible', timeout: 120_000 }); + await firstCard.waitFor({ state: 'visible', timeout: 30_000 }); // Store the current URL before clicking this.testContext.previousUrl = this.page.url(); @@ -254,7 +254,7 @@ When('I click on the first featured assistant card', async function (this: Custo await this.page.waitForFunction( (previousUrl) => window.location.href !== previousUrl, this.testContext.previousUrl, - { timeout: 120_000 }, + { timeout: 30_000 }, ); }); @@ -263,12 +263,12 @@ When('I click on the first featured assistant card', async function (this: Custo // ============================================ Then('I should see filtered assistant cards', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const assistantItems = this.page.locator('[data-testid="assistant-item"]'); // Wait for at least one item to be visible - await expect(assistantItems.first()).toBeVisible({ timeout: 120_000 }); + await expect(assistantItems.first()).toBeVisible({ timeout: 30_000 }); // Verify that at least one item exists const count = await assistantItems.count(); @@ -278,12 +278,12 @@ Then('I should see filtered assistant cards', async function (this: CustomWorld) Then( 'I should see assistant cards filtered by the selected category', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const assistantItems = this.page.locator('[data-testid="assistant-item"]'); // Wait for at least one item to be visible - await expect(assistantItems.first()).toBeVisible({ timeout: 120_000 }); + await expect(assistantItems.first()).toBeVisible({ timeout: 30_000 }); // Verify that at least one item exists const count = await assistantItems.count(); @@ -301,12 +301,12 @@ Then('the URL should contain the category parameter', async function (this: Cust }); Then('I should see different assistant cards', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const assistantItems = this.page.locator('[data-testid="assistant-item"]'); // Wait for at least one item to be visible - await expect(assistantItems.first()).toBeVisible({ timeout: 120_000 }); + await expect(assistantItems.first()).toBeVisible({ timeout: 30_000 }); // Verify that at least one item exists const count = await assistantItems.count(); @@ -323,7 +323,7 @@ Then('the URL should contain the page parameter', async function (this: CustomWo }); Then('I should be navigated to the assistant detail page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Verify that URL changed and contains /assistant/ followed by an identifier @@ -337,20 +337,20 @@ Then('I should be navigated to the assistant detail page', async function (this: }); Then('I should see the assistant detail content', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Look for detail page elements (e.g., title, description, etc.) const detailContent = this.page.locator('[data-testid="detail-content"], main, article').first(); - await expect(detailContent).toBeVisible({ timeout: 120_000 }); + await expect(detailContent).toBeVisible({ timeout: 30_000 }); }); Then('I should see model cards in the sorted order', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const modelItems = this.page.locator('[data-testid="model-item"]'); // Wait for at least one item to be visible - await expect(modelItems.first()).toBeVisible({ timeout: 120_000 }); + await expect(modelItems.first()).toBeVisible({ timeout: 30_000 }); // Verify that at least one item exists const count = await modelItems.count(); @@ -358,7 +358,7 @@ Then('I should see model cards in the sorted order', async function (this: Custo }); Then('I should be navigated to the model detail page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Verify that URL changed and contains /model/ followed by an identifier @@ -372,15 +372,15 @@ Then('I should be navigated to the model detail page', async function (this: Cus }); Then('I should see the model detail content', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Look for detail page elements const detailContent = this.page.locator('[data-testid="detail-content"], main, article').first(); - await expect(detailContent).toBeVisible({ timeout: 120_000 }); + await expect(detailContent).toBeVisible({ timeout: 30_000 }); }); Then('I should be navigated to the provider detail page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Verify that URL changed and contains /provider/ followed by an identifier @@ -394,22 +394,22 @@ Then('I should be navigated to the provider detail page', async function (this: }); Then('I should see the provider detail content', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Look for detail page elements const detailContent = this.page.locator('[data-testid="detail-content"], main, article').first(); - await expect(detailContent).toBeVisible({ timeout: 120_000 }); + await expect(detailContent).toBeVisible({ timeout: 30_000 }); }); Then( 'I should see MCP cards filtered by the selected category', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const mcpItems = this.page.locator('[data-testid="mcp-item"]'); // Wait for at least one item to be visible - await expect(mcpItems.first()).toBeVisible({ timeout: 120_000 }); + await expect(mcpItems.first()).toBeVisible({ timeout: 30_000 }); // Verify that at least one item exists const count = await mcpItems.count(); @@ -418,7 +418,7 @@ Then( ); Then('I should be navigated to the MCP detail page', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Verify that URL changed and contains /mcp/ followed by an identifier @@ -432,15 +432,15 @@ Then('I should be navigated to the MCP detail page', async function (this: Custo }); Then('I should see the MCP detail content', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); // Look for detail page elements const detailContent = this.page.locator('[data-testid="detail-content"], main, article').first(); - await expect(detailContent).toBeVisible({ timeout: 120_000 }); + await expect(detailContent).toBeVisible({ timeout: 30_000 }); }); Then('I should be navigated to {string}', async function (this: CustomWorld, expectedPath: string) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 30_000 }); const currentUrl = this.page.url(); // Verify that URL contains the expected path diff --git a/e2e/src/steps/discover/smoke.steps.ts b/e2e/src/steps/discover/smoke.steps.ts index 0f0323adcd..f57441879f 100644 --- a/e2e/src/steps/discover/smoke.steps.ts +++ b/e2e/src/steps/discover/smoke.steps.ts @@ -9,55 +9,50 @@ import { CustomWorld } from '../../support/world'; // Home Page Steps Then('I should see the featured assistants section', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 10_000 }); - // Look for featured assistants section by data-testid or heading + // Look for "Featured Agents" heading text (i18n key: home.featuredAssistants) + // Supports: en-US "Featured Agents", zh-CN "ๆŽจ่ๅŠฉ็†" const featuredSection = this.page - .locator( - '[data-testid="featured-assistants"], h2:has-text("Featured"), h3:has-text("Featured")', - ) + .getByRole('heading', { name: /featured agents|ๆŽจ่ๅŠฉ็†/i }) .first(); - await expect(featuredSection).toBeVisible({ timeout: 120_000 }); + await expect(featuredSection).toBeVisible({ timeout: 10_000 }); }); Then('I should see the featured MCP tools section', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 10_000 }); - // Look for featured MCP section by data-testid or heading - const mcpSection = this.page - .locator('[data-testid="featured-mcp"], h2:has-text("MCP"), h3:has-text("MCP")') - .first(); - await expect(mcpSection).toBeVisible({ timeout: 120_000 }); + // Look for "Featured Skills" heading text (i18n key: home.featuredTools) + // Supports: en-US "Featured Skills", zh-CN "ๆŽจ่ๆŠ€่ƒฝ" + const mcpSection = this.page.getByRole('heading', { name: /featured skills|ๆŽจ่ๆŠ€่ƒฝ/i }).first(); + await expect(mcpSection).toBeVisible({ timeout: 10_000 }); }); // Assistant List Page Steps Then('I should see the search bar', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 10_000 }); - // The SearchBar component from @lobehub/ui may not pass through data-testid - // Try to find the input element within the search component - const searchBar = this.page.locator('input[type="text"]').first(); - await expect(searchBar).toBeVisible({ timeout: 120_000 }); + // SearchBar component has data-testid="search-bar" + const searchBar = this.page.locator('[data-testid="search-bar"]').first(); + await expect(searchBar).toBeVisible({ timeout: 10_000 }); }); Then('I should see the category menu', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 10_000 }); - // Look for category menu/filter by data-testid or role - const categoryMenu = this.page - .locator('[data-testid="category-menu"], [role="menu"], nav[aria-label*="categor" i]') - .first(); - await expect(categoryMenu).toBeVisible({ timeout: 120_000 }); + // CategoryMenu component has data-testid="category-menu" + const categoryMenu = this.page.locator('[data-testid="category-menu"]').first(); + await expect(categoryMenu).toBeVisible({ timeout: 10_000 }); }); Then('I should see assistant cards', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 10_000 }); // Look for assistant items by data-testid const assistantItems = this.page.locator('[data-testid="assistant-item"]'); // Wait for at least one item to be visible - await expect(assistantItems.first()).toBeVisible({ timeout: 120_000 }); + await expect(assistantItems.first()).toBeVisible({ timeout: 10_000 }); // Check we have multiple items const count = await assistantItems.count(); @@ -65,26 +60,22 @@ Then('I should see assistant cards', async function (this: CustomWorld) { }); Then('I should see pagination controls', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 10_000 }); - // Look for pagination controls by data-testid, role, or common pagination elements - const pagination = this.page - .locator( - '[data-testid="pagination"], nav[aria-label*="pagination" i], .pagination, button:has-text("Next"), button:has-text("Previous")', - ) - .first(); - await expect(pagination).toBeVisible({ timeout: 120_000 }); + // Pagination component has data-testid="pagination" + const pagination = this.page.locator('[data-testid="pagination"]').first(); + await expect(pagination).toBeVisible({ timeout: 10_000 }); }); // Model List Page Steps Then('I should see model cards', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 10_000 }); - // Look for model items by data-testid + // Model items have data-testid="model-item" const modelItems = this.page.locator('[data-testid="model-item"]'); // Wait for at least one item to be visible - await expect(modelItems.first()).toBeVisible({ timeout: 120_000 }); + await expect(modelItems.first()).toBeVisible({ timeout: 10_000 }); // Check we have multiple items const count = await modelItems.count(); @@ -92,26 +83,22 @@ Then('I should see model cards', async function (this: CustomWorld) { }); Then('I should see the sort dropdown', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 10_000 }); - // Look for sort dropdown by data-testid, role, or select element - const sortDropdown = this.page - .locator( - '[data-testid="sort-dropdown"], select, button[aria-label*="sort" i], [role="combobox"]', - ) - .first(); - await expect(sortDropdown).toBeVisible({ timeout: 120_000 }); + // SortButton has data-testid="sort-dropdown" + const sortDropdown = this.page.locator('[data-testid="sort-dropdown"]').first(); + await expect(sortDropdown).toBeVisible({ timeout: 10_000 }); }); // Provider List Page Steps Then('I should see provider cards', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 10_000 }); // Look for provider items by data-testid const providerItems = this.page.locator('[data-testid="provider-item"]'); // Wait for at least one item to be visible - await expect(providerItems.first()).toBeVisible({ timeout: 120_000 }); + await expect(providerItems.first()).toBeVisible({ timeout: 10_000 }); // Check we have multiple items const count = await providerItems.count(); @@ -120,13 +107,13 @@ Then('I should see provider cards', async function (this: CustomWorld) { // MCP List Page Steps Then('I should see MCP cards', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 10_000 }); // Look for MCP items by data-testid const mcpItems = this.page.locator('[data-testid="mcp-item"]'); // Wait for at least one item to be visible - await expect(mcpItems.first()).toBeVisible({ timeout: 120_000 }); + await expect(mcpItems.first()).toBeVisible({ timeout: 10_000 }); // Check we have multiple items const count = await mcpItems.count(); @@ -134,13 +121,9 @@ Then('I should see MCP cards', async function (this: CustomWorld) { }); Then('I should see the category filter', async function (this: CustomWorld) { - await this.page.waitForLoadState('networkidle', { timeout: 120_000 }); + await this.page.waitForLoadState('networkidle', { timeout: 10_000 }); - // Look for category filter by data-testid or similar to category menu - const categoryFilter = this.page - .locator( - '[data-testid="category-filter"], [data-testid="category-menu"], [role="menu"], nav[aria-label*="categor" i]', - ) - .first(); - await expect(categoryFilter).toBeVisible({ timeout: 120_000 }); + // CategoryMenu component has data-testid="category-menu" (shared across list pages) + const categoryFilter = this.page.locator('[data-testid="category-menu"]').first(); + await expect(categoryFilter).toBeVisible({ timeout: 10_000 }); }); diff --git a/e2e/src/steps/hooks.ts b/e2e/src/steps/hooks.ts index 747d7f468c..126b2510f4 100644 --- a/e2e/src/steps/hooks.ts +++ b/e2e/src/steps/hooks.ts @@ -1,28 +1,80 @@ import { After, AfterAll, Before, BeforeAll, Status, setDefaultTimeout } from '@cucumber/cucumber'; +import { type Cookie, chromium } from 'playwright'; +import { TEST_USER, seedTestUser } from '../support/seedTestUser'; import { startWebServer, stopWebServer } from '../support/webServer'; import { CustomWorld } from '../support/world'; process.env['E2E'] = '1'; -// Set default timeout for all steps to 120 seconds -setDefaultTimeout(120_000); +// Set default timeout for all steps to 10 seconds +setDefaultTimeout(10_000); -BeforeAll({ timeout: 120_000 }, async function () { +// Store base URL and cached session cookies +let baseUrl: string; +let sessionCookies: Cookie[] = []; + +BeforeAll({ timeout: 600_000 }, async function () { console.log('๐Ÿš€ Starting E2E test suite...'); - const PORT = process.env.PORT ? Number(process.env.PORT) : 3010; - const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; + const PORT = process.env.PORT ? Number(process.env.PORT) : 3006; + baseUrl = process.env.BASE_URL || `http://localhost:${PORT}`; - console.log(`Base URL: ${BASE_URL}`); + console.log(`Base URL: ${baseUrl}`); + + // Seed test user before starting web server + await seedTestUser(); // Start web server if not using external BASE_URL if (!process.env.BASE_URL) { await startWebServer({ - command: 'npm run dev', + command: `bunx next start -p ${PORT}`, port: PORT, reuseExistingServer: !process.env.CI, + timeout: 60_000, }); } + + // Login once and cache the session cookies + console.log('๐Ÿ” Performing one-time login to cache session...'); + + const browser = await chromium.launch({ headless: process.env.HEADLESS !== 'false' }); + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + // Navigate to signin page + await page.goto(`${baseUrl}/signin`, { waitUntil: 'networkidle' }); + + // Step 1: Enter email + console.log(' Step 1: Entering email...'); + const emailInput = page.locator('input[id="email"]').first(); + await emailInput.waitFor({ state: 'visible', timeout: 30_000 }); + await emailInput.fill(TEST_USER.email); + + // Click the next button + const nextButton = page.locator('form button').first(); + await nextButton.click(); + + // Step 2: Wait for password step and enter password + console.log(' Step 2: Entering password...'); + const passwordInput = page.locator('input[id="password"]').first(); + await passwordInput.waitFor({ state: 'visible', timeout: 30_000 }); + await passwordInput.fill(TEST_USER.password); + + // Click submit button + const submitButton = page.locator('form button').first(); + await submitButton.click(); + + // Wait for navigation away from signin page + await page.waitForURL((url) => !url.pathname.includes('/signin'), { timeout: 30_000 }); + await page.waitForLoadState('networkidle'); + + // Cache the session cookies + sessionCookies = await context.cookies(); + console.log(`โœ… Login successful, cached ${sessionCookies.length} cookies`); + } finally { + await browser.close(); + } }); Before(async function (this: CustomWorld, { pickle }) { @@ -30,6 +82,15 @@ Before(async function (this: CustomWorld, { pickle }) { const testId = pickle.tags.find((tag) => tag.name.startsWith('@DISCOVER-')); console.log(`\n๐Ÿ“ Running: ${pickle.name}${testId ? ` (${testId.name.replace('@', '')})` : ''}`); + + // Setup API mocks before any page navigation + // await mockManager.setup(this.page); + + // Set cached session cookies to skip login + if (sessionCookies.length > 0) { + await this.browserContext.addCookies(sessionCookies); + console.log('๐Ÿช Session cookies restored'); + } }); After(async function (this: CustomWorld, { pickle, result }) { diff --git a/e2e/src/support/seedTestUser.ts b/e2e/src/support/seedTestUser.ts new file mode 100644 index 0000000000..7f87306ca8 --- /dev/null +++ b/e2e/src/support/seedTestUser.ts @@ -0,0 +1,126 @@ +import bcrypt from 'bcryptjs'; + +// Test user credentials - these are used for e2e testing only +export const TEST_USER = { + email: 'e2e-test@lobehub.com', + fullName: 'E2E Test User', + id: 'user_e2e_test_user_001', + password: 'TestPassword123!', + username: 'e2e_test_user', +}; + +/** + * Create a bcrypt password hash + * Better Auth supports bcrypt for passwords migrated from Clerk + */ +async function hashPassword(password: string): Promise { + return bcrypt.hash(password, 10); +} + +/** + * Seed test user into the database for e2e testing + * This function connects directly to PostgreSQL and creates the necessary records + */ +export async function seedTestUser(): Promise { + const databaseUrl = process.env.DATABASE_URL; + + if (!databaseUrl) { + console.log('โš ๏ธ DATABASE_URL not set, skipping test user seeding'); + return; + } + + // Dynamic import pg to avoid bundling issues + const { default: pg } = await import('pg'); + const client = new pg.Client({ connectionString: databaseUrl }); + + try { + await client.connect(); + console.log('๐Ÿ”Œ Connected to database for test user seeding'); + + const now = new Date().toISOString(); + // Use fixed account ID to avoid conflicts when multiple workers run concurrently + const accountId = 'e2e_test_account_001'; + + // Use upsert to handle concurrent worker execution + // Insert user or do nothing if already exists (handles all unique constraints) + const passwordHash = await hashPassword(TEST_USER.password); + + // Use ON CONFLICT DO NOTHING to handle all unique constraint conflicts + // This is safe because we're using fixed test user credentials + // Set onboarding as completed to skip onboarding flow in tests + const onboarding = JSON.stringify({ finishedAt: now, version: 1 }); + + await client.query( + `INSERT INTO users (id, email, normalized_email, username, full_name, email_verified, onboarding, created_at, updated_at, last_active_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, $8) + ON CONFLICT (id) DO UPDATE SET onboarding = $7, updated_at = $8`, + [ + TEST_USER.id, + TEST_USER.email, + TEST_USER.email.toLowerCase(), + TEST_USER.username, + TEST_USER.fullName, + true, // email_verified + onboarding, + now, + ], + ); + + // Create account record with password (for credential login) + await client.query( + `INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $6) + ON CONFLICT DO NOTHING`, + [ + accountId, + TEST_USER.id, + TEST_USER.email, // account_id is email for credential provider + 'credential', // provider_id + passwordHash, + now, + ], + ); + + console.log('โœ… Test user seeded successfully'); + console.log(` Email: ${TEST_USER.email}`); + console.log(` Password: ${TEST_USER.password}`); + } catch (error) { + console.error('โŒ Failed to seed test user:', error); + throw error; + } finally { + await client.end(); + } +} + +/** + * Clean up test user data after tests + */ +export async function cleanupTestUser(): Promise { + const databaseUrl = process.env.DATABASE_URL; + + if (!databaseUrl) { + return; + } + + const { default: pg } = await import('pg'); + const client = new pg.Client({ connectionString: databaseUrl }); + + try { + await client.connect(); + + // Delete sessions first (foreign key) + await client.query('DELETE FROM auth_sessions WHERE user_id = $1', [TEST_USER.id]); + + // Delete accounts (foreign key) + await client.query('DELETE FROM accounts WHERE user_id = $1', [TEST_USER.id]); + + // Delete user + await client.query('DELETE FROM users WHERE id = $1', [TEST_USER.id]); + + console.log('๐Ÿงน Test user cleaned up'); + } catch (error) { + console.error('โŒ Failed to cleanup test user:', error); + } finally { + await client.end(); + } +} diff --git a/e2e/src/support/webServer.ts b/e2e/src/support/webServer.ts index 977a442803..a2dd2b2603 100644 --- a/e2e/src/support/webServer.ts +++ b/e2e/src/support/webServer.ts @@ -1,9 +1,13 @@ import { type ChildProcess, exec } from 'node:child_process'; +import { existsSync, unlinkSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; let serverProcess: ChildProcess | null = null; let serverStartPromise: Promise | null = null; +// File-based lock to coordinate between parallel workers +const LOCK_FILE = resolve(__dirname, '../../.server-starting.lock'); + interface WebServerOptions { command: string; env?: Record; @@ -24,7 +28,7 @@ async function isServerRunning(port: number): Promise { } export async function startWebServer(options: WebServerOptions): Promise { - const { command, port, timeout = 120_000, env = {}, reuseExistingServer = true } = options; + const { command, port, timeout = 30_000, env = {}, reuseExistingServer = true } = options; // If server is already being started by another worker, wait for it if (serverStartPromise) { @@ -38,6 +42,51 @@ export async function startWebServer(options: WebServerOptions): Promise { return; } + // Check if another worker is starting the server (file-based lock for cross-process coordination) + if (existsSync(LOCK_FILE)) { + console.log(`โณ Another worker is starting the server, waiting...`); + const startTime = Date.now(); + while (!(await isServerRunning(port))) { + if (Date.now() - startTime > timeout) { + // Lock file might be stale, try to clean up and proceed + try { + unlinkSync(LOCK_FILE); + } catch { + // Ignore + } + break; + } + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + } + if (await isServerRunning(port)) { + console.log(`โœ… Server is now ready on port ${port}`); + return; + } + } + + // Create lock file to signal other workers + try { + writeFileSync(LOCK_FILE, String(process.pid)); + } catch { + // Another worker might have created it, check again + if (existsSync(LOCK_FILE)) { + console.log(`โณ Lock file created by another worker, waiting...`); + const startTime = Date.now(); + while (!(await isServerRunning(port))) { + if (Date.now() - startTime > timeout) { + throw new Error(`Server failed to start within ${timeout}ms`); + } + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + } + console.log(`โœ… Server is now ready on port ${port}`); + return; + } + } + // Create a promise for the server startup and store it serverStartPromise = (async () => { console.log(`๐Ÿš€ Starting web server: ${command}`); @@ -50,12 +99,21 @@ export async function startWebServer(options: WebServerOptions): Promise { cwd: projectRoot, env: { ...process.env, - ENABLE_AUTH_PROTECTION: '0', + // E2E test secret keys + BETTER_AUTH_SECRET: 'e2e-test-secret-key-for-better-auth-32chars!', ENABLE_OIDC: '0', - NEXT_PUBLIC_ENABLE_CLERK_AUTH: '0', - NEXT_PUBLIC_ENABLE_NEXT_AUTH: '0', + KEY_VAULTS_SECRET: 'LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=', + // Disable email verification for e2e + NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION: '0', + // Enable Better Auth for e2e tests with real authentication + NEXT_PUBLIC_ENABLE_BETTER_AUTH: '1', NODE_OPTIONS: '--max-old-space-size=6144', PORT: String(port), + // Mock S3 env vars to prevent initialization errors + S3_ACCESS_KEY_ID: 'e2e-mock-access-key', + S3_BUCKET: 'e2e-mock-bucket', + S3_ENDPOINT: 'https://e2e-mock-s3.localhost', + S3_SECRET_ACCESS_KEY: 'e2e-mock-secret-key', ...env, }, }); @@ -93,4 +151,10 @@ export async function stopWebServer(): Promise { serverProcess = null; serverStartPromise = null; } + // Clean up lock file + try { + unlinkSync(LOCK_FILE); + } catch { + // Ignore if file doesn't exist + } } diff --git a/e2e/src/support/world.ts b/e2e/src/support/world.ts index 3d4cf650c5..7ba9e641fa 100644 --- a/e2e/src/support/world.ts +++ b/e2e/src/support/world.ts @@ -1,5 +1,7 @@ import { IWorldOptions, World, setWorldConstructor } from '@cucumber/cucumber'; import { Browser, BrowserContext, Page, Response, chromium } from '@playwright/test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; export interface TestContext { [key: string]: any; @@ -29,7 +31,7 @@ export class CustomWorld extends World { } async init() { - const PORT = process.env.PORT ? Number(process.env.PORT) : 3010; + const PORT = process.env.PORT ? Number(process.env.PORT) : 3006; const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; this.browser = await chromium.launch({ @@ -42,7 +44,7 @@ export class CustomWorld extends World { }); // Set expect timeout for assertions (e.g., toBeVisible, toHaveText) - this.browserContext.setDefaultTimeout(120_000); + this.browserContext.setDefaultTimeout(30_000); this.page = await this.browserContext.newPage(); @@ -58,7 +60,7 @@ export class CustomWorld extends World { } }); - this.page.setDefaultTimeout(120_000); + this.page.setDefaultTimeout(30_000); } async cleanup() { @@ -68,8 +70,18 @@ export class CustomWorld extends World { } async takeScreenshot(name: string): Promise { - console.log(name); - return await this.page.screenshot({ fullPage: true }); + const screenshot = await this.page.screenshot({ fullPage: true }); + + // Save screenshot to file + const screenshotsDir = path.join(process.cwd(), 'screenshots'); + if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir, { recursive: true }); + } + const filepath = path.join(screenshotsDir, `${name}.png`); + fs.writeFileSync(filepath, screenshot); + console.log(`๐Ÿ“ธ Screenshot saved: ${filepath}`); + + return screenshot; } }