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:
Arvin Xu
2025-12-31 02:13:32 +08:00
committed by GitHub
parent 6e19bd3d4c
commit a9a93c15ae
19 changed files with 1224 additions and 203 deletions

View File

@@ -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,
};

View File

@@ -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": {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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' },
];

View 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,
];

View File

@@ -0,0 +1,7 @@
/**
* Discover/Community module mocks
*/
export * from './data';
export { discoverHandlers as discoverMocks } from './handlers';
export * from './types';

View 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
View 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();

View 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)');
});

View File

@@ -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();
});

View File

@@ -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

View File

@@ -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 });
});

View File

@@ -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 }) {

View 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();
}
}

View File

@@ -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
}
}

View File

@@ -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;
}
}