mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✅ test: fix e2e tests for new product flow (#11060)
* 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 <noreply@anthropic.com> * 🐛 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 <noreply@anthropic.com> * 🐛 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 <noreply@anthropic.com> * 🐛 fix(e2e): set onboarding as completed for test user Skip onboarding flow by setting finishedAt in test user seed
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
212
e2e/src/mocks/discover/data.ts
Normal file
212
e2e/src/mocks/discover/data.ts
Normal file
@@ -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' },
|
||||
];
|
||||
179
e2e/src/mocks/discover/handlers.ts
Normal file
179
e2e/src/mocks/discover/handlers.ts
Normal file
@@ -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<string, unknown>; 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<string, unknown> | 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,
|
||||
];
|
||||
7
e2e/src/mocks/discover/index.ts
Normal file
7
e2e/src/mocks/discover/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Discover/Community module mocks
|
||||
*/
|
||||
|
||||
export * from './data';
|
||||
export { discoverHandlers as discoverMocks } from './handlers';
|
||||
export * from './types';
|
||||
98
e2e/src/mocks/discover/types.ts
Normal file
98
e2e/src/mocks/discover/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
158
e2e/src/mocks/index.ts
Normal file
158
e2e/src/mocks/index.ts
Normal file
@@ -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<void>;
|
||||
/** 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<string, MockHandler[]>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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<MockConfig> = {}) {
|
||||
this.config = { ...defaultConfig, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup all mock handlers for a page
|
||||
*/
|
||||
async setup(page: Page): Promise<void> {
|
||||
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<T>(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<T>(data: T): string {
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Singleton Instance
|
||||
// ============================================
|
||||
|
||||
export const mockManager = new MockManager();
|
||||
100
e2e/src/steps/common/auth.steps.ts
Normal file
100
e2e/src/steps/common/auth.steps.ts
Normal file
@@ -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)');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
126
e2e/src/support/seedTestUser.ts
Normal file
126
e2e/src/support/seedTestUser.ts
Normal file
@@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<void> | null = null;
|
||||
|
||||
// File-based lock to coordinate between parallel workers
|
||||
const LOCK_FILE = resolve(__dirname, '../../.server-starting.lock');
|
||||
|
||||
interface WebServerOptions {
|
||||
command: string;
|
||||
env?: Record<string, string>;
|
||||
@@ -24,7 +28,7 @@ async function isServerRunning(port: number): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function startWebServer(options: WebServerOptions): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
serverProcess = null;
|
||||
serverStartPromise = null;
|
||||
}
|
||||
// Clean up lock file
|
||||
try {
|
||||
unlinkSync(LOCK_FILE);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Buffer> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user