mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 03:31:24 +08:00
Merge remote-tracking branch 'origin/main' into feat/proxy-api-server
This commit is contained in:
commit
5d1d2b7a9b
@ -11,6 +11,7 @@
|
||||
"dist/**",
|
||||
"out/**",
|
||||
"local/**",
|
||||
"tests/**",
|
||||
".yarn/**",
|
||||
".gitignore",
|
||||
"scripts/cloudflare-worker.js",
|
||||
|
||||
@ -58,6 +58,7 @@ export default defineConfig([
|
||||
'dist/**',
|
||||
'out/**',
|
||||
'local/**',
|
||||
'tests/**',
|
||||
'.yarn/**',
|
||||
'.gitignore',
|
||||
'scripts/cloudflare-worker.js',
|
||||
|
||||
@ -172,7 +172,7 @@
|
||||
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||
"@opeoginni/github-copilot-openai-compatible": "^0.1.21",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.12.0",
|
||||
@ -321,7 +321,6 @@
|
||||
"p-queue": "^8.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"playwright": "^1.55.1",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
|
||||
@ -1,42 +1,64 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
* Playwright configuration for Electron e2e testing.
|
||||
* See https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
// Look for test files, relative to this configuration file.
|
||||
testDir: './tests/e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
// Look for test files in the specs directory
|
||||
testDir: './tests/e2e/specs',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry'
|
||||
// Global timeout for each test
|
||||
timeout: 60000,
|
||||
|
||||
// Assertion timeout
|
||||
expect: {
|
||||
timeout: 10000
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
// Electron apps should run tests sequentially to avoid conflicts
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
// Fail the build on CI if you accidentally left test.only in the source code
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
// Retry on CI only
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Reporter configuration
|
||||
reporter: [['html', { outputFolder: 'playwright-report' }], ['list']],
|
||||
|
||||
// Global setup and teardown
|
||||
globalSetup: './tests/e2e/global-setup.ts',
|
||||
globalTeardown: './tests/e2e/global-teardown.ts',
|
||||
|
||||
// Output directory for test artifacts
|
||||
outputDir: './test-results',
|
||||
|
||||
// Shared settings for all tests
|
||||
use: {
|
||||
// Collect trace when retrying the failed test
|
||||
trace: 'retain-on-failure',
|
||||
|
||||
// Take screenshot only on failure
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
// Record video only on failure
|
||||
video: 'retain-on-failure',
|
||||
|
||||
// Action timeout
|
||||
actionTimeout: 15000,
|
||||
|
||||
// Navigation timeout
|
||||
navigationTimeout: 30000
|
||||
},
|
||||
|
||||
// Single project for Electron testing
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
name: 'electron',
|
||||
testMatch: '**/*.spec.ts'
|
||||
}
|
||||
]
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// url: 'http://localhost:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
})
|
||||
|
||||
@ -548,6 +548,17 @@ class CodeToolsService {
|
||||
logger.debug(`Environment variables:`, Object.keys(env))
|
||||
logger.debug(`Options:`, options)
|
||||
|
||||
// Validate directory exists before proceeding
|
||||
if (!directory || !fs.existsSync(directory)) {
|
||||
const errorMessage = `Directory does not exist: ${directory}`
|
||||
logger.error(errorMessage)
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
command: ''
|
||||
}
|
||||
}
|
||||
|
||||
const packageName = await this.getPackageName(cliTool)
|
||||
const bunPath = await this.getBunPath()
|
||||
const executableName = await this.getCliExecutableName(cliTool)
|
||||
@ -709,6 +720,7 @@ class CodeToolsService {
|
||||
// Build bat file content, including debug information
|
||||
const batContent = [
|
||||
'@echo off',
|
||||
'chcp 65001 >nul 2>&1', // Switch to UTF-8 code page for international path support
|
||||
`title ${cliTool} - Cherry Studio`, // Set window title in bat file
|
||||
'echo ================================================',
|
||||
'echo Cherry Studio CLI Tool Launcher',
|
||||
|
||||
@ -405,6 +405,9 @@ export abstract class BaseApiClient<
|
||||
if (!param.name?.trim()) {
|
||||
return acc
|
||||
}
|
||||
// Parse JSON type parameters (Legacy API clients)
|
||||
// Related: src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx:133-148
|
||||
// The UI stores JSON type params as strings, this function parses them before sending to API
|
||||
if (param.type === 'json') {
|
||||
const value = param.value as string
|
||||
if (value === 'undefined') {
|
||||
|
||||
@ -684,6 +684,10 @@ export function getCustomParameters(assistant: Assistant): Record<string, any> {
|
||||
if (!param.name?.trim()) {
|
||||
return acc
|
||||
}
|
||||
// Parse JSON type parameters
|
||||
// Related: src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx:133-148
|
||||
// The UI stores JSON type params as strings (e.g., '{"key":"value"}')
|
||||
// This function parses them into objects before sending to the API
|
||||
if (param.type === 'json') {
|
||||
const value = param.value as string
|
||||
if (value === 'undefined') {
|
||||
|
||||
@ -125,200 +125,376 @@ describe('model utils', () => {
|
||||
openAIWebSearchOnlyMock.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('detects OpenAI LLM models through reasoning and GPT prefix', () => {
|
||||
expect(isOpenAILLMModel(undefined as unknown as Model)).toBe(false)
|
||||
expect(isOpenAILLMModel(createModel({ id: 'gpt-4o-image' }))).toBe(false)
|
||||
describe('OpenAI model detection', () => {
|
||||
describe('isOpenAILLMModel', () => {
|
||||
it('returns false for undefined model', () => {
|
||||
expect(isOpenAILLMModel(undefined as unknown as Model)).toBe(false)
|
||||
})
|
||||
|
||||
reasoningMock.mockReturnValueOnce(true)
|
||||
expect(isOpenAILLMModel(createModel({ id: 'o1-preview' }))).toBe(true)
|
||||
it('returns false for image generation models', () => {
|
||||
expect(isOpenAILLMModel(createModel({ id: 'gpt-4o-image' }))).toBe(false)
|
||||
})
|
||||
|
||||
expect(isOpenAILLMModel(createModel({ id: 'GPT-5-turbo' }))).toBe(true)
|
||||
})
|
||||
it('returns true for reasoning models', () => {
|
||||
reasoningMock.mockReturnValueOnce(true)
|
||||
expect(isOpenAILLMModel(createModel({ id: 'o1-preview' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects OpenAI models via GPT prefix or reasoning support', () => {
|
||||
expect(isOpenAIModel(createModel({ id: 'gpt-4.1' }))).toBe(true)
|
||||
reasoningMock.mockReturnValueOnce(true)
|
||||
expect(isOpenAIModel(createModel({ id: 'o3' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('evaluates support for flex service tier and alias helper', () => {
|
||||
expect(isSupportFlexServiceTierModel(createModel({ id: 'o3' }))).toBe(true)
|
||||
expect(isSupportFlexServiceTierModel(createModel({ id: 'o3-mini' }))).toBe(false)
|
||||
expect(isSupportFlexServiceTierModel(createModel({ id: 'o4-mini' }))).toBe(true)
|
||||
expect(isSupportFlexServiceTierModel(createModel({ id: 'gpt-5-preview' }))).toBe(true)
|
||||
expect(isSupportedFlexServiceTier(createModel({ id: 'gpt-4o' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('detects verbosity support for GPT-5+ families', () => {
|
||||
expect(isSupportVerbosityModel(createModel({ id: 'gpt-5' }))).toBe(true)
|
||||
expect(isSupportVerbosityModel(createModel({ id: 'gpt-5-chat' }))).toBe(false)
|
||||
expect(isSupportVerbosityModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('limits verbosity controls for GPT-5 Pro models', () => {
|
||||
const proModel = createModel({ id: 'gpt-5-pro' })
|
||||
const previewModel = createModel({ id: 'gpt-5-preview' })
|
||||
expect(getModelSupportedVerbosity(proModel)).toEqual([undefined, 'high'])
|
||||
expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, 'low', 'medium', 'high'])
|
||||
expect(isGPT5ProModel(proModel)).toBe(true)
|
||||
expect(isGPT5ProModel(previewModel)).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies OpenAI chat-completion-only models', () => {
|
||||
expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o-search-preview' }))).toBe(true)
|
||||
expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'o1-mini' }))).toBe(true)
|
||||
expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('filters unsupported OpenAI catalog entries', () => {
|
||||
expect(isSupportedModel({ id: 'gpt-4', object: 'model' } as any)).toBe(true)
|
||||
expect(isSupportedModel({ id: 'tts-1', object: 'model' } as any)).toBe(false)
|
||||
})
|
||||
|
||||
it('calculates temperature/top-p support correctly', () => {
|
||||
const model = createModel({ id: 'o1' })
|
||||
reasoningMock.mockReturnValue(true)
|
||||
expect(isNotSupportTemperatureAndTopP(model)).toBe(true)
|
||||
|
||||
const openWeight = createModel({ id: 'gpt-oss-debug' })
|
||||
expect(isNotSupportTemperatureAndTopP(openWeight)).toBe(false)
|
||||
|
||||
const chatOnly = createModel({ id: 'o1-preview' })
|
||||
reasoningMock.mockReturnValue(false)
|
||||
expect(isNotSupportTemperatureAndTopP(chatOnly)).toBe(true)
|
||||
|
||||
const qwenMt = createModel({ id: 'qwen-mt-large', provider: 'aliyun' })
|
||||
expect(isNotSupportTemperatureAndTopP(qwenMt)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles gemma and gemini detections plus zhipu tagging', () => {
|
||||
expect(isGemmaModel(createModel({ id: 'Gemma-3-27B' }))).toBe(true)
|
||||
expect(isGemmaModel(createModel({ group: 'Gemma' }))).toBe(true)
|
||||
expect(isGemmaModel(createModel({ id: 'gpt-4o' }))).toBe(false)
|
||||
|
||||
expect(isGeminiModel(createModel({ id: 'Gemini-2.0' }))).toBe(true)
|
||||
|
||||
expect(isZhipuModel(createModel({ provider: 'zhipu' }))).toBe(true)
|
||||
expect(isZhipuModel(createModel({ provider: 'openai' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('groups qwen models by prefix', () => {
|
||||
const qwen = createModel({ id: 'Qwen-7B', provider: 'qwen', name: 'Qwen-7B' })
|
||||
const qwenOmni = createModel({ id: 'qwen2.5-omni', name: 'qwen2.5-omni' })
|
||||
const other = createModel({ id: 'deepseek-v3', group: 'DeepSeek' })
|
||||
|
||||
const grouped = groupQwenModels([qwen, qwenOmni, other])
|
||||
expect(Object.keys(grouped)).toContain('qwen-7b')
|
||||
expect(Object.keys(grouped)).toContain('qwen2.5')
|
||||
expect(grouped.DeepSeek).toContain(other)
|
||||
})
|
||||
|
||||
it('aggregates boolean helpers based on regex rules', () => {
|
||||
expect(isAnthropicModel(createModel({ id: 'claude-3.5' }))).toBe(true)
|
||||
expect(isQwenMTModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true)
|
||||
expect(isNotSupportSystemMessageModel(createModel({ id: 'gemma-moe' }))).toBe(true)
|
||||
expect(isOpenAIOpenWeightModel(createModel({ id: 'gpt-oss-free' }))).toBe(true)
|
||||
})
|
||||
|
||||
describe('isNotSupportedTextDelta', () => {
|
||||
it('returns true for qwen-mt-turbo and qwen-mt-plus models', () => {
|
||||
// qwen-mt series that don't support text delta
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo' }))).toBe(true)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Turbo' }))).toBe(true)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'QWEN-MT-PLUS' }))).toBe(true)
|
||||
it('returns true for GPT-prefixed models', () => {
|
||||
expect(isOpenAILLMModel(createModel({ id: 'GPT-5-turbo' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns false for qwen-mt-flash and other models', () => {
|
||||
// qwen-mt-flash supports text delta
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-flash' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Flash' }))).toBe(false)
|
||||
describe('isOpenAIModel', () => {
|
||||
it('detects models via GPT prefix', () => {
|
||||
expect(isOpenAIModel(createModel({ id: 'gpt-4.1' }))).toBe(true)
|
||||
})
|
||||
|
||||
// Legacy qwen models without mt prefix (support text delta)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus' }))).toBe(false)
|
||||
|
||||
// Other qwen models
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-max' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen2.5-72b' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-vl-plus' }))).toBe(false)
|
||||
|
||||
// Non-qwen models
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'gpt-4o' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'claude-3.5' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'glm-4-plus' }))).toBe(false)
|
||||
it('detects models via reasoning support', () => {
|
||||
reasoningMock.mockReturnValueOnce(true)
|
||||
expect(isOpenAIModel(createModel({ id: 'o3' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles models with version suffixes', () => {
|
||||
// qwen-mt models with version suffixes
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo-1201' }))).toBe(true)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus-0828' }))).toBe(true)
|
||||
describe('isOpenAIChatCompletionOnlyModel', () => {
|
||||
it('identifies chat-completion-only models', () => {
|
||||
expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o-search-preview' }))).toBe(true)
|
||||
expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'o1-mini' }))).toBe(true)
|
||||
})
|
||||
|
||||
// Legacy qwen models with version suffixes (support text delta)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo-0828' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus-latest' }))).toBe(false)
|
||||
it('returns false for general models', () => {
|
||||
expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('evaluates GPT-5 family helpers', () => {
|
||||
expect(isGPT5SeriesModel(createModel({ id: 'gpt-5-preview' }))).toBe(true)
|
||||
expect(isGPT5SeriesModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(false)
|
||||
expect(isGPT51SeriesModel(createModel({ id: 'gpt-5.1-mini' }))).toBe(true)
|
||||
expect(isGPT5SeriesReasoningModel(createModel({ id: 'gpt-5-prompt' }))).toBe(true)
|
||||
expect(isSupportVerbosityModel(createModel({ id: 'gpt-5-chat' }))).toBe(false)
|
||||
describe('GPT-5 family detection', () => {
|
||||
describe('isGPT5SeriesModel', () => {
|
||||
it('returns true for GPT-5 models', () => {
|
||||
expect(isGPT5SeriesModel(createModel({ id: 'gpt-5-preview' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for GPT-5.1 models', () => {
|
||||
expect(isGPT5SeriesModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGPT51SeriesModel', () => {
|
||||
it('returns true for GPT-5.1 models', () => {
|
||||
expect(isGPT51SeriesModel(createModel({ id: 'gpt-5.1-mini' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGPT5SeriesReasoningModel', () => {
|
||||
it('returns true for GPT-5 reasoning models', () => {
|
||||
expect(isGPT5SeriesReasoningModel(createModel({ id: 'gpt-5' }))).toBe(true)
|
||||
})
|
||||
it('returns false for gpt-5-chat', () => {
|
||||
expect(isGPT5SeriesReasoningModel(createModel({ id: 'gpt-5-chat' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGPT5ProModel', () => {
|
||||
it('returns true for GPT-5 Pro models', () => {
|
||||
expect(isGPT5ProModel(createModel({ id: 'gpt-5-pro' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-Pro GPT-5 models', () => {
|
||||
expect(isGPT5ProModel(createModel({ id: 'gpt-5-preview' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('wraps generate/vision helpers that operate on arrays', () => {
|
||||
const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })]
|
||||
expect(isVisionModels(models)).toBe(true)
|
||||
visionMock.mockReturnValueOnce(true).mockReturnValueOnce(false)
|
||||
expect(isVisionModels(models)).toBe(false)
|
||||
describe('Verbosity support', () => {
|
||||
describe('isSupportVerbosityModel', () => {
|
||||
it('returns true for GPT-5 models', () => {
|
||||
expect(isSupportVerbosityModel(createModel({ id: 'gpt-5' }))).toBe(true)
|
||||
})
|
||||
|
||||
generateImageMock.mockReturnValue(true)
|
||||
expect(isGenerateImageModels(models)).toBe(true)
|
||||
generateImageMock.mockReturnValueOnce(true).mockReturnValueOnce(false)
|
||||
expect(isGenerateImageModels(models)).toBe(false)
|
||||
it('returns false for GPT-5 chat models', () => {
|
||||
expect(isSupportVerbosityModel(createModel({ id: 'gpt-5-chat' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for GPT-5.1 models', () => {
|
||||
expect(isSupportVerbosityModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getModelSupportedVerbosity', () => {
|
||||
it('returns only "high" for GPT-5 Pro models', () => {
|
||||
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro' }))).toEqual([undefined, 'high'])
|
||||
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro-2025-10-06' }))).toEqual([undefined, 'high'])
|
||||
})
|
||||
|
||||
it('returns all levels for non-Pro GPT-5 models', () => {
|
||||
const previewModel = createModel({ id: 'gpt-5-preview' })
|
||||
expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, 'low', 'medium', 'high'])
|
||||
})
|
||||
|
||||
it('returns all levels for GPT-5.1 models', () => {
|
||||
const gpt51Model = createModel({ id: 'gpt-5.1-preview' })
|
||||
expect(getModelSupportedVerbosity(gpt51Model)).toEqual([undefined, 'low', 'medium', 'high'])
|
||||
})
|
||||
|
||||
it('returns only undefined for non-GPT-5 models', () => {
|
||||
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-4o' }))).toEqual([undefined])
|
||||
expect(getModelSupportedVerbosity(createModel({ id: 'claude-3.5' }))).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('returns only undefined for undefiend/null input', () => {
|
||||
expect(getModelSupportedVerbosity(undefined)).toEqual([undefined])
|
||||
expect(getModelSupportedVerbosity(null)).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('filters models for agent usage', () => {
|
||||
expect(agentModelFilter(createModel())).toBe(true)
|
||||
describe('Flex service tier support', () => {
|
||||
describe('isSupportFlexServiceTierModel', () => {
|
||||
it('returns true for supported models', () => {
|
||||
expect(isSupportFlexServiceTierModel(createModel({ id: 'o3' }))).toBe(true)
|
||||
expect(isSupportFlexServiceTierModel(createModel({ id: 'o4-mini' }))).toBe(true)
|
||||
expect(isSupportFlexServiceTierModel(createModel({ id: 'gpt-5-preview' }))).toBe(true)
|
||||
})
|
||||
|
||||
embeddingMock.mockReturnValueOnce(true)
|
||||
expect(agentModelFilter(createModel({ id: 'text-embedding' }))).toBe(false)
|
||||
it('returns false for unsupported models', () => {
|
||||
expect(isSupportFlexServiceTierModel(createModel({ id: 'o3-mini' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
embeddingMock.mockReturnValue(false)
|
||||
rerankMock.mockReturnValueOnce(true)
|
||||
expect(agentModelFilter(createModel({ id: 'rerank' }))).toBe(false)
|
||||
describe('isSupportedFlexServiceTier', () => {
|
||||
it('returns false for non-flex models', () => {
|
||||
expect(isSupportedFlexServiceTier(createModel({ id: 'gpt-4o' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
rerankMock.mockReturnValue(false)
|
||||
textToImageMock.mockReturnValueOnce(true)
|
||||
expect(agentModelFilter(createModel({ id: 'gpt-image-1' }))).toBe(false)
|
||||
describe('Temperature and top-p support', () => {
|
||||
describe('isNotSupportTemperatureAndTopP', () => {
|
||||
it('returns true for reasoning models', () => {
|
||||
const model = createModel({ id: 'o1' })
|
||||
reasoningMock.mockReturnValue(true)
|
||||
expect(isNotSupportTemperatureAndTopP(model)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for open weight models', () => {
|
||||
const openWeight = createModel({ id: 'gpt-oss-debug' })
|
||||
expect(isNotSupportTemperatureAndTopP(openWeight)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for chat-only models without reasoning', () => {
|
||||
const chatOnly = createModel({ id: 'o1-preview' })
|
||||
reasoningMock.mockReturnValue(false)
|
||||
expect(isNotSupportTemperatureAndTopP(chatOnly)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for Qwen MT models', () => {
|
||||
const qwenMt = createModel({ id: 'qwen-mt-large', provider: 'aliyun' })
|
||||
expect(isNotSupportTemperatureAndTopP(qwenMt)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text delta support', () => {
|
||||
describe('isNotSupportTextDeltaModel', () => {
|
||||
it('returns true for qwen-mt-turbo and qwen-mt-plus models', () => {
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo' }))).toBe(true)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Turbo' }))).toBe(true)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'QWEN-MT-PLUS' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for qwen-mt-flash and other models', () => {
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-flash' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Flash' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-max' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen2.5-72b' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-vl-plus' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-qwen models', () => {
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'gpt-4o' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'claude-3.5' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'glm-4-plus' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('handles models with version suffixes', () => {
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo-1201' }))).toBe(true)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus-0828' }))).toBe(true)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo-0828' }))).toBe(false)
|
||||
expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus-latest' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Model provider detection', () => {
|
||||
describe('isGemmaModel', () => {
|
||||
it('detects Gemma models by ID', () => {
|
||||
expect(isGemmaModel(createModel({ id: 'Gemma-3-27B' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects Gemma models by group', () => {
|
||||
expect(isGemmaModel(createModel({ group: 'Gemma' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-Gemma models', () => {
|
||||
expect(isGemmaModel(createModel({ id: 'gpt-4o' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGeminiModel', () => {
|
||||
it('detects Gemini models', () => {
|
||||
expect(isGeminiModel(createModel({ id: 'Gemini-2.0' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isZhipuModel', () => {
|
||||
it('detects Zhipu models by provider', () => {
|
||||
expect(isZhipuModel(createModel({ provider: 'zhipu' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-Zhipu models', () => {
|
||||
expect(isZhipuModel(createModel({ provider: 'openai' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAnthropicModel', () => {
|
||||
it('detects Anthropic models', () => {
|
||||
expect(isAnthropicModel(createModel({ id: 'claude-3.5' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isQwenMTModel', () => {
|
||||
it('detects Qwen MT models', () => {
|
||||
expect(isQwenMTModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOpenAIOpenWeightModel', () => {
|
||||
it('detects OpenAI open weight models', () => {
|
||||
expect(isOpenAIOpenWeightModel(createModel({ id: 'gpt-oss-free' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('System message support', () => {
|
||||
describe('isNotSupportSystemMessageModel', () => {
|
||||
it('returns true for models that do not support system messages', () => {
|
||||
expect(isNotSupportSystemMessageModel(createModel({ id: 'gemma-moe' }))).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Model grouping', () => {
|
||||
describe('groupQwenModels', () => {
|
||||
it('groups qwen models by prefix', () => {
|
||||
const qwen = createModel({ id: 'Qwen-7B', provider: 'qwen', name: 'Qwen-7B' })
|
||||
const qwenOmni = createModel({ id: 'qwen2.5-omni', name: 'qwen2.5-omni' })
|
||||
const other = createModel({ id: 'deepseek-v3', group: 'DeepSeek' })
|
||||
|
||||
const grouped = groupQwenModels([qwen, qwenOmni, other])
|
||||
expect(Object.keys(grouped)).toContain('qwen-7b')
|
||||
expect(Object.keys(grouped)).toContain('qwen2.5')
|
||||
expect(grouped.DeepSeek).toContain(other)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Vision and image generation', () => {
|
||||
describe('isVisionModels', () => {
|
||||
it('returns true when all models support vision', () => {
|
||||
const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })]
|
||||
expect(isVisionModels(models)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when some models do not support vision', () => {
|
||||
const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })]
|
||||
visionMock.mockReturnValueOnce(true).mockReturnValueOnce(false)
|
||||
expect(isVisionModels(models)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGenerateImageModels', () => {
|
||||
it('returns true when all models support image generation', () => {
|
||||
const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })]
|
||||
generateImageMock.mockReturnValue(true)
|
||||
expect(isGenerateImageModels(models)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when some models do not support image generation', () => {
|
||||
const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })]
|
||||
generateImageMock.mockReturnValueOnce(true).mockReturnValueOnce(false)
|
||||
expect(isGenerateImageModels(models)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Model filtering', () => {
|
||||
describe('isSupportedModel', () => {
|
||||
it('filters supported OpenAI catalog entries', () => {
|
||||
expect(isSupportedModel({ id: 'gpt-4', object: 'model' } as any)).toBe(true)
|
||||
})
|
||||
|
||||
it('filters unsupported OpenAI catalog entries', () => {
|
||||
expect(isSupportedModel({ id: 'tts-1', object: 'model' } as any)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentModelFilter', () => {
|
||||
it('returns true for regular models', () => {
|
||||
expect(agentModelFilter(createModel())).toBe(true)
|
||||
})
|
||||
|
||||
it('filters out embedding models', () => {
|
||||
embeddingMock.mockReturnValueOnce(true)
|
||||
expect(agentModelFilter(createModel({ id: 'text-embedding' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('filters out rerank models', () => {
|
||||
embeddingMock.mockReturnValue(false)
|
||||
rerankMock.mockReturnValueOnce(true)
|
||||
expect(agentModelFilter(createModel({ id: 'rerank' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('filters out text-to-image models', () => {
|
||||
rerankMock.mockReturnValue(false)
|
||||
textToImageMock.mockReturnValueOnce(true)
|
||||
expect(agentModelFilter(createModel({ id: 'gpt-image-1' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
textToImageMock.mockReturnValue(false)
|
||||
generateImageMock.mockReturnValueOnce(true)
|
||||
expect(agentModelFilter(createModel({ id: 'dall-e-3' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies models with maximum temperature of 1.0', () => {
|
||||
// Zhipu models should have max temperature of 1.0
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'glm-4' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'GLM-4-Plus' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'glm-3-turbo' }))).toBe(true)
|
||||
describe('Temperature limits', () => {
|
||||
describe('isMaxTemperatureOneModel', () => {
|
||||
it('returns true for Zhipu models', () => {
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'glm-4' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'GLM-4-Plus' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'glm-3-turbo' }))).toBe(true)
|
||||
})
|
||||
|
||||
// Anthropic models should have max temperature of 1.0
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'claude-3.5-sonnet' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'Claude-3-opus' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'claude-2.1' }))).toBe(true)
|
||||
it('returns true for Anthropic models', () => {
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'claude-3.5-sonnet' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'Claude-3-opus' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'claude-2.1' }))).toBe(true)
|
||||
})
|
||||
|
||||
// Moonshot models should have max temperature of 1.0
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'moonshot-1.0' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'kimi-k2-thinking' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'Moonshot-Pro' }))).toBe(true)
|
||||
it('returns true for Moonshot models', () => {
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'moonshot-1.0' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'kimi-k2-thinking' }))).toBe(true)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'Moonshot-Pro' }))).toBe(true)
|
||||
})
|
||||
|
||||
// Other models should return false
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4o' }))).toBe(false)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4-turbo' }))).toBe(false)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'qwen-max' }))).toBe(false)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'gemini-pro' }))).toBe(false)
|
||||
it('returns false for other models', () => {
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4o' }))).toBe(false)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4-turbo' }))).toBe(false)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'qwen-max' }))).toBe(false)
|
||||
expect(isMaxTemperatureOneModel(createModel({ id: 'gemini-pro' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,7 +4,14 @@ import { type Model, SystemProviderIds } from '@renderer/types'
|
||||
import type { OpenAIVerbosity, ValidOpenAIVerbosity } from '@renderer/types/aiCoreTypes'
|
||||
import { getLowerBaseModelName } from '@renderer/utils'
|
||||
|
||||
import { isOpenAIChatCompletionOnlyModel, isOpenAIOpenWeightModel, isOpenAIReasoningModel } from './openai'
|
||||
import {
|
||||
isGPT5ProModel,
|
||||
isGPT5SeriesModel,
|
||||
isGPT51SeriesModel,
|
||||
isOpenAIChatCompletionOnlyModel,
|
||||
isOpenAIOpenWeightModel,
|
||||
isOpenAIReasoningModel
|
||||
} from './openai'
|
||||
import { isQwenMTModel } from './qwen'
|
||||
import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision'
|
||||
export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i
|
||||
@ -123,21 +130,46 @@ export const isNotSupportSystemMessageModel = (model: Model): boolean => {
|
||||
return isQwenMTModel(model) || isGemmaModel(model)
|
||||
}
|
||||
|
||||
// GPT-5 verbosity configuration
|
||||
// Verbosity settings is only supported by GPT-5 and newer models
|
||||
// Specifically, GPT-5 and GPT-5.1 for now
|
||||
// gpt-5-pro only supports 'high', other GPT-5 models support all levels
|
||||
export const MODEL_SUPPORTED_VERBOSITY: Record<string, ValidOpenAIVerbosity[]> = {
|
||||
'gpt-5-pro': ['high'],
|
||||
default: ['low', 'medium', 'high']
|
||||
} as const
|
||||
const MODEL_SUPPORTED_VERBOSITY: readonly {
|
||||
readonly validator: (model: Model) => boolean
|
||||
readonly values: readonly ValidOpenAIVerbosity[]
|
||||
}[] = [
|
||||
// gpt-5-pro
|
||||
{ validator: isGPT5ProModel, values: ['high'] },
|
||||
// gpt-5 except gpt-5-pro
|
||||
{
|
||||
validator: (model: Model) => isGPT5SeriesModel(model) && !isGPT5ProModel(model),
|
||||
values: ['low', 'medium', 'high']
|
||||
},
|
||||
// gpt-5.1
|
||||
{ validator: isGPT51SeriesModel, values: ['low', 'medium', 'high'] }
|
||||
]
|
||||
|
||||
export const getModelSupportedVerbosity = (model: Model): OpenAIVerbosity[] => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
let supportedValues: ValidOpenAIVerbosity[]
|
||||
if (modelId.includes('gpt-5-pro')) {
|
||||
supportedValues = MODEL_SUPPORTED_VERBOSITY['gpt-5-pro']
|
||||
} else {
|
||||
supportedValues = MODEL_SUPPORTED_VERBOSITY.default
|
||||
/**
|
||||
* Returns the list of supported verbosity levels for the given model.
|
||||
* If the model is not recognized as a GPT-5 series model, only `undefined` is returned.
|
||||
* For GPT-5-pro, only 'high' is supported; for other GPT-5 models, 'low', 'medium', and 'high' are supported.
|
||||
* For GPT-5.1 series models, 'low', 'medium', and 'high' are supported.
|
||||
* @param model - The model to check
|
||||
* @returns An array of supported verbosity levels, always including `undefined` as the first element
|
||||
*/
|
||||
export const getModelSupportedVerbosity = (model: Model | undefined | null): OpenAIVerbosity[] => {
|
||||
if (!model) {
|
||||
return [undefined]
|
||||
}
|
||||
|
||||
let supportedValues: ValidOpenAIVerbosity[] = []
|
||||
|
||||
for (const { validator, values } of MODEL_SUPPORTED_VERBOSITY) {
|
||||
if (validator(model)) {
|
||||
supportedValues = [...values]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return [undefined, ...supportedValues]
|
||||
}
|
||||
|
||||
|
||||
@ -404,11 +404,11 @@ const UpdateNotesWrapper = styled.div`
|
||||
margin: 8px 0;
|
||||
background-color: var(--color-bg-2);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 14px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@ -135,12 +135,18 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
<Input
|
||||
value={typeof param.value === 'string' ? param.value : JSON.stringify(param.value, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const jsonValue = JSON.parse(e.target.value)
|
||||
onUpdateCustomParameter(index, 'value', jsonValue)
|
||||
} catch {
|
||||
onUpdateCustomParameter(index, 'value', e.target.value)
|
||||
}
|
||||
// For JSON type parameters, always store the value as a STRING
|
||||
//
|
||||
// Data Flow:
|
||||
// 1. UI stores: { name: "config", value: '{"key":"value"}', type: "json" } ← STRING format
|
||||
// 2. API parses: getCustomParameters() in src/renderer/src/aiCore/utils/reasoning.ts:687-696
|
||||
// calls JSON.parse() to convert string to object
|
||||
// 3. Request sends: The parsed object is sent to the AI provider
|
||||
//
|
||||
// Previously this code was parsing JSON here and storing
|
||||
// the object directly, which caused getCustomParameters() to fail when trying
|
||||
// to JSON.parse() an already-parsed object.
|
||||
onUpdateCustomParameter(index, 'value', e.target.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -216,7 +216,7 @@ const assistantsSlice = createSlice({
|
||||
if (agent.id === action.payload.assistantId) {
|
||||
for (const key in settings) {
|
||||
if (!agent.settings) {
|
||||
agent.settings = DEFAULT_ASSISTANT_SETTINGS
|
||||
agent.settings = { ...DEFAULT_ASSISTANT_SETTINGS }
|
||||
}
|
||||
agent.settings[key] = settings[key]
|
||||
}
|
||||
|
||||
310
tests/e2e/README.md
Normal file
310
tests/e2e/README.md
Normal file
@ -0,0 +1,310 @@
|
||||
# E2E Testing Guide
|
||||
|
||||
本目录包含 Cherry Studio 的端到端 (E2E) 测试,使用 Playwright 测试 Electron 应用。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tests/e2e/
|
||||
├── README.md # 本文档
|
||||
├── global-setup.ts # 全局测试初始化
|
||||
├── global-teardown.ts # 全局测试清理
|
||||
├── fixtures/
|
||||
│ └── electron.fixture.ts # Electron 应用启动 fixture
|
||||
├── utils/
|
||||
│ ├── wait-helpers.ts # 等待辅助函数
|
||||
│ └── index.ts # 工具导出
|
||||
├── pages/ # Page Object Model
|
||||
│ ├── base.page.ts # 基础页面对象类
|
||||
│ ├── sidebar.page.ts # 侧边栏导航
|
||||
│ ├── home.page.ts # 首页/聊天页
|
||||
│ ├── settings.page.ts # 设置页
|
||||
│ ├── chat.page.ts # 聊天交互
|
||||
│ └── index.ts # 页面对象导出
|
||||
└── specs/ # 测试用例
|
||||
├── app-launch.spec.ts # 应用启动测试
|
||||
├── navigation.spec.ts # 页面导航测试
|
||||
├── settings/ # 设置相关测试
|
||||
│ └── general.spec.ts
|
||||
└── conversation/ # 对话相关测试
|
||||
└── basic-chat.spec.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运行测试
|
||||
|
||||
### 前置条件
|
||||
|
||||
1. 安装依赖:`yarn install`
|
||||
2. 构建应用:`yarn build`
|
||||
|
||||
### 运行命令
|
||||
|
||||
```bash
|
||||
# 运行所有 e2e 测试
|
||||
yarn test:e2e
|
||||
|
||||
# 带可视化窗口运行(可以看到测试过程)
|
||||
yarn test:e2e --headed
|
||||
|
||||
# 运行特定测试文件
|
||||
yarn playwright test tests/e2e/specs/app-launch.spec.ts
|
||||
|
||||
# 运行匹配名称的测试
|
||||
yarn playwright test -g "should launch"
|
||||
|
||||
# 调试模式(会暂停并打开调试器)
|
||||
yarn playwright test --debug
|
||||
|
||||
# 使用 Playwright UI 模式
|
||||
yarn playwright test --ui
|
||||
|
||||
# 查看测试报告
|
||||
yarn playwright show-report
|
||||
```
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Q: 测试时看不到窗口?**
|
||||
A: 默认是 headless 模式,使用 `--headed` 参数可看到窗口。
|
||||
|
||||
**Q: 测试失败,提示找不到元素?**
|
||||
A:
|
||||
1. 确保已运行 `yarn build` 构建最新代码
|
||||
2. 检查选择器是否正确,UI 可能已更新
|
||||
|
||||
**Q: 测试超时?**
|
||||
A: Electron 应用启动较慢,可在测试中增加超时时间:
|
||||
```typescript
|
||||
test.setTimeout(60000) // 60秒
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI 助手指南:创建新测试用例
|
||||
|
||||
以下内容供 AI 助手(如 Claude、GPT)在创建新测试用例时参考。
|
||||
|
||||
### 基本原则
|
||||
|
||||
1. **使用 Page Object Model (POM)**:所有页面交互应通过 `pages/` 目录下的页面对象进行
|
||||
2. **使用自定义 fixture**:从 `../fixtures/electron.fixture` 导入 `test` 和 `expect`
|
||||
3. **等待策略**:使用 `utils/wait-helpers.ts` 中的等待函数,避免硬编码 `waitForTimeout`
|
||||
4. **测试独立性**:每个测试应该独立运行,不依赖其他测试的状态
|
||||
|
||||
### 创建新测试文件
|
||||
|
||||
```typescript
|
||||
// tests/e2e/specs/[feature]/[feature].spec.ts
|
||||
|
||||
import { test, expect } from '../../fixtures/electron.fixture'
|
||||
import { SomePageObject } from '../../pages/some.page'
|
||||
import { waitForAppReady } from '../../utils/wait-helpers'
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
let pageObject: SomePageObject
|
||||
|
||||
test.beforeEach(async ({ mainWindow }) => {
|
||||
await waitForAppReady(mainWindow)
|
||||
pageObject = new SomePageObject(mainWindow)
|
||||
})
|
||||
|
||||
test('should do something', async ({ mainWindow }) => {
|
||||
// 测试逻辑
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 创建新页面对象
|
||||
|
||||
```typescript
|
||||
// tests/e2e/pages/[feature].page.ts
|
||||
|
||||
import { Page, Locator } from '@playwright/test'
|
||||
import { BasePage } from './base.page'
|
||||
|
||||
export class FeaturePage extends BasePage {
|
||||
// 定义页面元素定位器
|
||||
readonly someButton: Locator
|
||||
readonly someInput: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
// 使用多种选择器策略,提高稳定性
|
||||
this.someButton = page.locator('[class*="SomeButton"], button:has-text("Some Text")')
|
||||
this.someInput = page.locator('input[placeholder*="placeholder"]')
|
||||
}
|
||||
|
||||
// 页面操作方法
|
||||
async doSomething(): Promise<void> {
|
||||
await this.someButton.click()
|
||||
}
|
||||
|
||||
// 状态检查方法
|
||||
async isSomethingVisible(): Promise<boolean> {
|
||||
return this.someButton.isVisible()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 选择器最佳实践
|
||||
|
||||
```typescript
|
||||
// 优先级从高到低:
|
||||
|
||||
// 1. data-testid(最稳定,但需要在源码中添加)
|
||||
page.locator('[data-testid="submit-button"]')
|
||||
|
||||
// 2. 语义化角色
|
||||
page.locator('button[role="submit"]')
|
||||
page.locator('[aria-label="Send message"]')
|
||||
|
||||
// 3. 类名模糊匹配(适应 CSS Modules / styled-components)
|
||||
page.locator('[class*="SendButton"]')
|
||||
page.locator('[class*="send-button"]')
|
||||
|
||||
// 4. 文本内容
|
||||
page.locator('button:has-text("发送")')
|
||||
page.locator('text=Submit')
|
||||
|
||||
// 5. 组合选择器(提高稳定性)
|
||||
page.locator('[class*="ChatInput"] textarea, [class*="InputBar"] textarea')
|
||||
|
||||
// 避免使用:
|
||||
// - 精确类名(容易因构建变化而失效)
|
||||
// - 层级过深的选择器
|
||||
// - 索引选择器(如 nth-child)除非必要
|
||||
```
|
||||
|
||||
### 等待策略
|
||||
|
||||
```typescript
|
||||
import { waitForAppReady, waitForNavigation, waitForModal } from '../../utils/wait-helpers'
|
||||
|
||||
// 等待应用就绪
|
||||
await waitForAppReady(mainWindow)
|
||||
|
||||
// 等待导航完成(HashRouter)
|
||||
await waitForNavigation(mainWindow, '/settings')
|
||||
|
||||
// 等待模态框出现
|
||||
await waitForModal(mainWindow)
|
||||
|
||||
// 等待元素可见
|
||||
await page.locator('.some-element').waitFor({ state: 'visible', timeout: 10000 })
|
||||
|
||||
// 等待元素消失
|
||||
await page.locator('.loading').waitFor({ state: 'hidden' })
|
||||
|
||||
// 避免使用固定等待时间
|
||||
// BAD: await page.waitForTimeout(3000)
|
||||
// GOOD: await page.waitForSelector('.element', { state: 'visible' })
|
||||
```
|
||||
|
||||
### 断言模式
|
||||
|
||||
```typescript
|
||||
// 使用 Playwright 的自动重试断言
|
||||
await expect(page.locator('.element')).toBeVisible()
|
||||
await expect(page.locator('.element')).toHaveText('expected text')
|
||||
await expect(page.locator('.element')).toHaveCount(3)
|
||||
|
||||
// 检查 URL(HashRouter)
|
||||
await expect(page).toHaveURL(/.*#\/settings.*/)
|
||||
|
||||
// 软断言(不会立即失败)
|
||||
await expect.soft(page.locator('.element')).toBeVisible()
|
||||
|
||||
// 自定义超时
|
||||
await expect(page.locator('.slow-element')).toBeVisible({ timeout: 30000 })
|
||||
```
|
||||
|
||||
### 处理 Electron 特性
|
||||
|
||||
```typescript
|
||||
// 访问 Electron 主进程
|
||||
const bounds = await electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
return win?.getBounds()
|
||||
})
|
||||
|
||||
// 检查窗口状态
|
||||
const isMaximized = await electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
return win?.isMaximized()
|
||||
})
|
||||
|
||||
// 调用 IPC(通过 preload 暴露的 API)
|
||||
const result = await mainWindow.evaluate(() => {
|
||||
return (window as any).api.someMethod()
|
||||
})
|
||||
```
|
||||
|
||||
### 测试文件命名规范
|
||||
|
||||
```
|
||||
specs/
|
||||
├── [feature].spec.ts # 单文件测试
|
||||
├── [feature]/
|
||||
│ ├── [sub-feature].spec.ts # 子功能测试
|
||||
│ └── [another].spec.ts
|
||||
```
|
||||
|
||||
示例:
|
||||
- `app-launch.spec.ts` - 应用启动
|
||||
- `navigation.spec.ts` - 页面导航
|
||||
- `settings/general.spec.ts` - 通用设置
|
||||
- `conversation/basic-chat.spec.ts` - 基础聊天
|
||||
|
||||
### 添加新页面对象后的清单
|
||||
|
||||
1. 在 `pages/` 目录创建 `[feature].page.ts`
|
||||
2. 继承 `BasePage` 类
|
||||
3. 在 `pages/index.ts` 中导出
|
||||
4. 在对应的 spec 文件中导入使用
|
||||
|
||||
### 测试用例编写清单
|
||||
|
||||
- [ ] 使用自定义 fixture (`test`, `expect`)
|
||||
- [ ] 在 `beforeEach` 中调用 `waitForAppReady`
|
||||
- [ ] 使用 Page Object 进行页面交互
|
||||
- [ ] 使用描述性的测试名称
|
||||
- [ ] 添加适当的断言
|
||||
- [ ] 处理可能的异步操作
|
||||
- [ ] 考虑测试失败时的清理
|
||||
|
||||
### 调试技巧
|
||||
|
||||
```typescript
|
||||
// 截图调试
|
||||
await mainWindow.screenshot({ path: 'debug.png' })
|
||||
|
||||
// 打印页面 HTML
|
||||
console.log(await mainWindow.content())
|
||||
|
||||
// 暂停测试进行调试
|
||||
await mainWindow.pause()
|
||||
|
||||
// 打印元素数量
|
||||
console.log(await page.locator('.element').count())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置文件
|
||||
|
||||
主要配置在项目根目录的 `playwright.config.ts`:
|
||||
|
||||
- `testDir`: 测试目录 (`./tests/e2e/specs`)
|
||||
- `timeout`: 测试超时 (60秒)
|
||||
- `workers`: 并发数 (1,Electron 需要串行)
|
||||
- `retries`: 重试次数 (CI 环境下为 2)
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Playwright 官方文档](https://playwright.dev/docs/intro)
|
||||
- [Playwright Electron 测试](https://playwright.dev/docs/api/class-electron)
|
||||
- [Page Object Model](https://playwright.dev/docs/pom)
|
||||
53
tests/e2e/fixtures/electron.fixture.ts
Normal file
53
tests/e2e/fixtures/electron.fixture.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type { ElectronApplication, Page } from '@playwright/test'
|
||||
import { _electron as electron, test as base } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Custom fixtures for Electron e2e testing.
|
||||
* Provides electronApp and mainWindow to all tests.
|
||||
*/
|
||||
export type ElectronFixtures = {
|
||||
electronApp: ElectronApplication
|
||||
mainWindow: Page
|
||||
}
|
||||
|
||||
export const test = base.extend<ElectronFixtures>({
|
||||
electronApp: async ({}, use) => {
|
||||
// Launch Electron app from project root
|
||||
// The args ['.'] tells Electron to load the app from current directory
|
||||
const electronApp = await electron.launch({
|
||||
args: ['.'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'development'
|
||||
},
|
||||
timeout: 60000
|
||||
})
|
||||
|
||||
await use(electronApp)
|
||||
|
||||
// Cleanup: close the app after test
|
||||
await electronApp.close()
|
||||
},
|
||||
|
||||
mainWindow: async ({ electronApp }, use) => {
|
||||
// Wait for the main window (title: "Cherry Studio", not "Quick Assistant")
|
||||
// On Mac, the app may create miniWindow for QuickAssistant with different title
|
||||
const mainWindow = await electronApp.waitForEvent('window', {
|
||||
predicate: async (window) => {
|
||||
const title = await window.title()
|
||||
return title === 'Cherry Studio'
|
||||
},
|
||||
timeout: 60000
|
||||
})
|
||||
|
||||
// Wait for React app to mount
|
||||
await mainWindow.waitForSelector('#root', { state: 'attached', timeout: 60000 })
|
||||
|
||||
// Wait for initial content to load
|
||||
await mainWindow.waitForLoadState('domcontentloaded')
|
||||
|
||||
await use(mainWindow)
|
||||
}
|
||||
})
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
25
tests/e2e/global-setup.ts
Normal file
25
tests/e2e/global-setup.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
/**
|
||||
* Global setup for Playwright e2e tests.
|
||||
* This runs once before all tests.
|
||||
*/
|
||||
async function globalSetup() {
|
||||
console.log('Running global setup...')
|
||||
|
||||
// Create test results directories
|
||||
const resultsDir = path.join(process.cwd(), 'test-results')
|
||||
const screenshotsDir = path.join(resultsDir, 'screenshots')
|
||||
|
||||
if (!fs.existsSync(screenshotsDir)) {
|
||||
fs.mkdirSync(screenshotsDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Set environment variables for testing
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
console.log('Global setup complete')
|
||||
}
|
||||
|
||||
export default globalSetup
|
||||
16
tests/e2e/global-teardown.ts
Normal file
16
tests/e2e/global-teardown.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Global teardown for Playwright e2e tests.
|
||||
* This runs once after all tests complete.
|
||||
*/
|
||||
async function globalTeardown() {
|
||||
console.log('Running global teardown...')
|
||||
|
||||
// Cleanup tasks can be added here:
|
||||
// - Kill orphaned Electron processes
|
||||
// - Clean up temporary test data
|
||||
// - Reset test databases
|
||||
|
||||
console.log('Global teardown complete')
|
||||
}
|
||||
|
||||
export default globalTeardown
|
||||
@ -1,13 +0,0 @@
|
||||
import { _electron as electron, expect, test } from '@playwright/test'
|
||||
|
||||
let electronApp: any
|
||||
let window: any
|
||||
|
||||
test.describe('App Launch', () => {
|
||||
test('should launch and close the main application', async () => {
|
||||
electronApp = await electron.launch({ args: ['.'] })
|
||||
window = await electronApp.firstWindow()
|
||||
expect(window).toBeDefined()
|
||||
await electronApp.close()
|
||||
})
|
||||
})
|
||||
110
tests/e2e/pages/base.page.ts
Normal file
110
tests/e2e/pages/base.page.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
/**
|
||||
* Base Page Object class.
|
||||
* All page objects should extend this class.
|
||||
*/
|
||||
export abstract class BasePage {
|
||||
constructor(protected page: Page) {}
|
||||
|
||||
/**
|
||||
* Navigate to a path using HashRouter.
|
||||
* The app uses HashRouter, so we need to change window.location.hash.
|
||||
*/
|
||||
async navigateTo(routePath: string): Promise<void> {
|
||||
await this.page.evaluate((p) => {
|
||||
window.location.hash = p
|
||||
}, routePath)
|
||||
await this.page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element to be visible.
|
||||
*/
|
||||
async waitForElement(selector: string, timeout: number = 10000): Promise<Locator> {
|
||||
const locator = this.page.locator(selector)
|
||||
await locator.waitFor({ state: 'visible', timeout })
|
||||
return locator
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element to be hidden.
|
||||
*/
|
||||
async waitForElementHidden(selector: string, timeout: number = 10000): Promise<void> {
|
||||
const locator = this.page.locator(selector)
|
||||
await locator.waitFor({ state: 'hidden', timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot for debugging.
|
||||
*/
|
||||
async takeScreenshot(name: string): Promise<void> {
|
||||
const screenshotsDir = path.join(process.cwd(), 'test-results', 'screenshots')
|
||||
if (!fs.existsSync(screenshotsDir)) {
|
||||
fs.mkdirSync(screenshotsDir, { recursive: true })
|
||||
}
|
||||
|
||||
await this.page.screenshot({
|
||||
path: path.join(screenshotsDir, `${name}.png`),
|
||||
fullPage: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current route from the hash.
|
||||
*/
|
||||
async getCurrentRoute(): Promise<string> {
|
||||
const url = this.page.url()
|
||||
const hash = new URL(url).hash
|
||||
return hash.replace('#', '') || '/'
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an element with retry.
|
||||
*/
|
||||
async clickWithRetry(selector: string, maxRetries: number = 3): Promise<void> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
await this.page.click(selector, { timeout: 5000 })
|
||||
return
|
||||
} catch (error) {
|
||||
if (i === maxRetries - 1) throw error
|
||||
await this.page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill an input field.
|
||||
*/
|
||||
async fillInput(selector: string, value: string): Promise<void> {
|
||||
const input = this.page.locator(selector)
|
||||
await input.fill(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text content of an element.
|
||||
*/
|
||||
async getTextContent(selector: string): Promise<string | null> {
|
||||
const locator = this.page.locator(selector)
|
||||
return locator.textContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is visible.
|
||||
*/
|
||||
async isElementVisible(selector: string): Promise<boolean> {
|
||||
const locator = this.page.locator(selector)
|
||||
return locator.isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Count elements matching a selector.
|
||||
*/
|
||||
async countElements(selector: string): Promise<number> {
|
||||
const locator = this.page.locator(selector)
|
||||
return locator.count()
|
||||
}
|
||||
}
|
||||
140
tests/e2e/pages/chat.page.ts
Normal file
140
tests/e2e/pages/chat.page.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BasePage } from './base.page'
|
||||
|
||||
/**
|
||||
* Page Object for the Chat/Conversation interface.
|
||||
* Handles message input, sending, and conversation management.
|
||||
*/
|
||||
export class ChatPage extends BasePage {
|
||||
readonly chatContainer: Locator
|
||||
readonly inputArea: Locator
|
||||
readonly sendButton: Locator
|
||||
readonly messageList: Locator
|
||||
readonly userMessages: Locator
|
||||
readonly assistantMessages: Locator
|
||||
readonly newTopicButton: Locator
|
||||
readonly topicList: Locator
|
||||
readonly stopButton: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.chatContainer = page.locator('#chat, [class*="Chat"]')
|
||||
this.inputArea = page.locator(
|
||||
'[class*="Inputbar"] textarea, [class*="InputBar"] textarea, [contenteditable="true"]'
|
||||
)
|
||||
this.sendButton = page.locator(
|
||||
'[class*="SendMessageButton"], [class*="send-button"], button[aria-label*="send"], button[title*="send"]'
|
||||
)
|
||||
this.messageList = page.locator('#messages, [class*="Messages"], [class*="MessageList"]')
|
||||
this.userMessages = page.locator('[class*="UserMessage"], [class*="user-message"]')
|
||||
this.assistantMessages = page.locator('[class*="AssistantMessage"], [class*="assistant-message"]')
|
||||
this.newTopicButton = page.locator('[class*="NewTopicButton"], [class*="new-topic"]')
|
||||
this.topicList = page.locator('[class*="TopicList"], [class*="topic-list"]')
|
||||
this.stopButton = page.locator('[class*="StopButton"], [class*="stop-button"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to chat/home page.
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.navigateTo('/')
|
||||
await this.chatContainer
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 15000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if chat is visible.
|
||||
*/
|
||||
async isChatVisible(): Promise<boolean> {
|
||||
return this.chatContainer.first().isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Type a message in the input area.
|
||||
*/
|
||||
async typeMessage(message: string): Promise<void> {
|
||||
await this.inputArea.first().fill(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the input area.
|
||||
*/
|
||||
async clearInput(): Promise<void> {
|
||||
await this.inputArea.first().clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the send button.
|
||||
*/
|
||||
async clickSend(): Promise<void> {
|
||||
await this.sendButton.first().click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Type and send a message.
|
||||
*/
|
||||
async sendMessage(message: string): Promise<void> {
|
||||
await this.typeMessage(message)
|
||||
await this.clickSend()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current input value.
|
||||
*/
|
||||
async getInputValue(): Promise<string> {
|
||||
return (await this.inputArea.first().inputValue()) || (await this.inputArea.first().textContent()) || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of user messages.
|
||||
*/
|
||||
async getUserMessageCount(): Promise<number> {
|
||||
return this.userMessages.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of assistant messages.
|
||||
*/
|
||||
async getAssistantMessageCount(): Promise<number> {
|
||||
return this.assistantMessages.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if send button is enabled.
|
||||
*/
|
||||
async isSendButtonEnabled(): Promise<boolean> {
|
||||
const isDisabled = await this.sendButton.first().isDisabled()
|
||||
return !isDisabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new topic/conversation.
|
||||
*/
|
||||
async createNewTopic(): Promise<void> {
|
||||
await this.newTopicButton.first().click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if stop button is visible (indicates ongoing generation).
|
||||
*/
|
||||
async isGenerating(): Promise<boolean> {
|
||||
return this.stopButton.first().isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Click stop button to stop generation.
|
||||
*/
|
||||
async stopGeneration(): Promise<void> {
|
||||
await this.stopButton.first().click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for generation to complete.
|
||||
*/
|
||||
async waitForGenerationComplete(timeout: number = 60000): Promise<void> {
|
||||
await this.stopButton.first().waitFor({ state: 'hidden', timeout })
|
||||
}
|
||||
}
|
||||
110
tests/e2e/pages/home.page.ts
Normal file
110
tests/e2e/pages/home.page.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BasePage } from './base.page'
|
||||
|
||||
/**
|
||||
* Page Object for the Home/Chat page.
|
||||
* This is the main page where users interact with AI assistants.
|
||||
*/
|
||||
export class HomePage extends BasePage {
|
||||
readonly homePage: Locator
|
||||
readonly chatContainer: Locator
|
||||
readonly inputBar: Locator
|
||||
readonly messagesList: Locator
|
||||
readonly sendButton: Locator
|
||||
readonly newTopicButton: Locator
|
||||
readonly assistantTabs: Locator
|
||||
readonly topicList: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.homePage = page.locator('#home-page, [class*="HomePage"], [class*="Home"]')
|
||||
this.chatContainer = page.locator('#chat, [class*="Chat"]')
|
||||
this.inputBar = page.locator('[class*="Inputbar"], [class*="InputBar"], [class*="input-bar"]')
|
||||
this.messagesList = page.locator('#messages, [class*="Messages"], [class*="MessageList"]')
|
||||
this.sendButton = page.locator('[class*="SendMessageButton"], [class*="send-button"], button[type="submit"]')
|
||||
this.newTopicButton = page.locator('[class*="NewTopicButton"], [class*="new-topic"]')
|
||||
this.assistantTabs = page.locator('[class*="HomeTabs"], [class*="AssistantTabs"]')
|
||||
this.topicList = page.locator('[class*="TopicList"], [class*="topic-list"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the home page.
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.navigateTo('/')
|
||||
await this.homePage
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 15000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the home page is loaded.
|
||||
*/
|
||||
async isLoaded(): Promise<boolean> {
|
||||
return this.homePage.first().isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Type a message in the input area.
|
||||
*/
|
||||
async typeMessage(message: string): Promise<void> {
|
||||
const input = this.page.locator(
|
||||
'[class*="Inputbar"] textarea, [class*="Inputbar"] [contenteditable], [class*="InputBar"] textarea'
|
||||
)
|
||||
await input.first().fill(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the send button to send a message.
|
||||
*/
|
||||
async sendMessage(): Promise<void> {
|
||||
await this.sendButton.first().click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Type and send a message.
|
||||
*/
|
||||
async sendChatMessage(message: string): Promise<void> {
|
||||
await this.typeMessage(message)
|
||||
await this.sendMessage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of messages in the chat.
|
||||
*/
|
||||
async getMessageCount(): Promise<number> {
|
||||
const messages = this.page.locator('[class*="Message"]:not([class*="Messages"]):not([class*="MessageList"])')
|
||||
return messages.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new topic/conversation.
|
||||
*/
|
||||
async createNewTopic(): Promise<void> {
|
||||
await this.newTopicButton.first().click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the chat interface is visible.
|
||||
*/
|
||||
async isChatVisible(): Promise<boolean> {
|
||||
return this.chatContainer.first().isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input bar is visible.
|
||||
*/
|
||||
async isInputBarVisible(): Promise<boolean> {
|
||||
return this.inputBar.first().isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the placeholder text of the input field.
|
||||
*/
|
||||
async getInputPlaceholder(): Promise<string | null> {
|
||||
const input = this.page.locator('[class*="Inputbar"] textarea, [class*="InputBar"] textarea')
|
||||
return input.first().getAttribute('placeholder')
|
||||
}
|
||||
}
|
||||
8
tests/e2e/pages/index.ts
Normal file
8
tests/e2e/pages/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Export all page objects for easy importing.
|
||||
*/
|
||||
export { BasePage } from './base.page'
|
||||
export { ChatPage } from './chat.page'
|
||||
export { HomePage } from './home.page'
|
||||
export { SettingsPage } from './settings.page'
|
||||
export { SidebarPage } from './sidebar.page'
|
||||
159
tests/e2e/pages/settings.page.ts
Normal file
159
tests/e2e/pages/settings.page.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BasePage } from './base.page'
|
||||
|
||||
/**
|
||||
* Page Object for the Settings page.
|
||||
* Handles navigation and interaction with various settings sections.
|
||||
*/
|
||||
export class SettingsPage extends BasePage {
|
||||
readonly settingsContainer: Locator
|
||||
readonly providerMenuItem: Locator
|
||||
readonly modelMenuItem: Locator
|
||||
readonly generalMenuItem: Locator
|
||||
readonly displayMenuItem: Locator
|
||||
readonly dataMenuItem: Locator
|
||||
readonly mcpMenuItem: Locator
|
||||
readonly memoryMenuItem: Locator
|
||||
readonly aboutMenuItem: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.settingsContainer = page.locator('[id="content-container"], [class*="Settings"]')
|
||||
this.providerMenuItem = page.locator('a[href*="/settings/provider"]')
|
||||
this.modelMenuItem = page.locator('a[href*="/settings/model"]')
|
||||
this.generalMenuItem = page.locator('a[href*="/settings/general"]')
|
||||
this.displayMenuItem = page.locator('a[href*="/settings/display"]')
|
||||
this.dataMenuItem = page.locator('a[href*="/settings/data"]')
|
||||
this.mcpMenuItem = page.locator('a[href*="/settings/mcp"]')
|
||||
this.memoryMenuItem = page.locator('a[href*="/settings/memory"]')
|
||||
this.aboutMenuItem = page.locator('a[href*="/settings/about"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to settings page (provider by default).
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.navigateTo('/settings/provider')
|
||||
await this.waitForElement('[id="content-container"], [class*="Settings"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if settings page is loaded.
|
||||
*/
|
||||
async isLoaded(): Promise<boolean> {
|
||||
return this.settingsContainer.first().isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Provider settings.
|
||||
*/
|
||||
async goToProvider(): Promise<void> {
|
||||
try {
|
||||
await this.providerMenuItem.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/settings/provider')
|
||||
}
|
||||
await this.page.waitForURL('**/#/settings/provider**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Model settings.
|
||||
*/
|
||||
async goToModel(): Promise<void> {
|
||||
try {
|
||||
await this.modelMenuItem.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/settings/model')
|
||||
}
|
||||
await this.page.waitForURL('**/#/settings/model**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to General settings.
|
||||
*/
|
||||
async goToGeneral(): Promise<void> {
|
||||
try {
|
||||
await this.generalMenuItem.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/settings/general')
|
||||
}
|
||||
await this.page.waitForURL('**/#/settings/general**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Display settings.
|
||||
*/
|
||||
async goToDisplay(): Promise<void> {
|
||||
try {
|
||||
await this.displayMenuItem.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/settings/display')
|
||||
}
|
||||
await this.page.waitForURL('**/#/settings/display**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Data settings.
|
||||
*/
|
||||
async goToData(): Promise<void> {
|
||||
try {
|
||||
await this.dataMenuItem.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/settings/data')
|
||||
}
|
||||
await this.page.waitForURL('**/#/settings/data**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to MCP settings.
|
||||
*/
|
||||
async goToMCP(): Promise<void> {
|
||||
try {
|
||||
await this.mcpMenuItem.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/settings/mcp')
|
||||
}
|
||||
await this.page.waitForURL('**/#/settings/mcp**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Memory settings.
|
||||
*/
|
||||
async goToMemory(): Promise<void> {
|
||||
try {
|
||||
await this.memoryMenuItem.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/settings/memory')
|
||||
}
|
||||
await this.page.waitForURL('**/#/settings/memory**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to About page.
|
||||
*/
|
||||
async goToAbout(): Promise<void> {
|
||||
try {
|
||||
await this.aboutMenuItem.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/settings/about')
|
||||
}
|
||||
await this.page.waitForURL('**/#/settings/about**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a switch setting by its label.
|
||||
*/
|
||||
async toggleSwitch(label: string): Promise<void> {
|
||||
const switchElement = this.page.locator(`text=${label}`).locator('..').locator('button[role="switch"], .ant-switch')
|
||||
await switchElement.first().click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a menu item is active/selected.
|
||||
*/
|
||||
async isMenuItemActive(menuItem: Locator): Promise<boolean> {
|
||||
const className = await menuItem.getAttribute('class')
|
||||
return className?.includes('active') || className?.includes('selected') || false
|
||||
}
|
||||
}
|
||||
122
tests/e2e/pages/sidebar.page.ts
Normal file
122
tests/e2e/pages/sidebar.page.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { BasePage } from './base.page'
|
||||
|
||||
/**
|
||||
* Page Object for the Sidebar/Navigation component.
|
||||
* Handles navigation between different sections of the app.
|
||||
*/
|
||||
export class SidebarPage extends BasePage {
|
||||
readonly sidebar: Locator
|
||||
readonly homeLink: Locator
|
||||
readonly storeLink: Locator
|
||||
readonly knowledgeLink: Locator
|
||||
readonly filesLink: Locator
|
||||
readonly settingsLink: Locator
|
||||
readonly appsLink: Locator
|
||||
readonly translateLink: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page)
|
||||
this.sidebar = page.locator('[class*="Sidebar"], nav, aside')
|
||||
this.homeLink = page.locator('a[href="#/"], a[href="#!/"]').first()
|
||||
this.storeLink = page.locator('a[href*="/store"]')
|
||||
this.knowledgeLink = page.locator('a[href*="/knowledge"]')
|
||||
this.filesLink = page.locator('a[href*="/files"]')
|
||||
this.settingsLink = page.locator('a[href*="/settings"]')
|
||||
this.appsLink = page.locator('a[href*="/apps"]')
|
||||
this.translateLink = page.locator('a[href*="/translate"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Home page.
|
||||
*/
|
||||
async goToHome(): Promise<void> {
|
||||
// Try clicking the home link, or navigate directly
|
||||
try {
|
||||
await this.homeLink.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/')
|
||||
}
|
||||
await this.page.waitForURL(/.*#\/$|.*#$|.*#\/home.*/, { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Knowledge page.
|
||||
*/
|
||||
async goToKnowledge(): Promise<void> {
|
||||
try {
|
||||
await this.knowledgeLink.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/knowledge')
|
||||
}
|
||||
await this.page.waitForURL('**/#/knowledge**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Settings page.
|
||||
*/
|
||||
async goToSettings(): Promise<void> {
|
||||
try {
|
||||
await this.settingsLink.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/settings/provider')
|
||||
}
|
||||
await this.page.waitForURL('**/#/settings/**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Files page.
|
||||
*/
|
||||
async goToFiles(): Promise<void> {
|
||||
try {
|
||||
await this.filesLink.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/files')
|
||||
}
|
||||
await this.page.waitForURL('**/#/files**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Apps page.
|
||||
*/
|
||||
async goToApps(): Promise<void> {
|
||||
try {
|
||||
await this.appsLink.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/apps')
|
||||
}
|
||||
await this.page.waitForURL('**/#/apps**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Store page.
|
||||
*/
|
||||
async goToStore(): Promise<void> {
|
||||
try {
|
||||
await this.storeLink.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/store')
|
||||
}
|
||||
await this.page.waitForURL('**/#/store**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Translate page.
|
||||
*/
|
||||
async goToTranslate(): Promise<void> {
|
||||
try {
|
||||
await this.translateLink.click({ timeout: 5000 })
|
||||
} catch {
|
||||
await this.navigateTo('/translate')
|
||||
}
|
||||
await this.page.waitForURL('**/#/translate**', { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if sidebar is visible.
|
||||
*/
|
||||
async isVisible(): Promise<boolean> {
|
||||
return this.sidebar.first().isVisible()
|
||||
}
|
||||
}
|
||||
49
tests/e2e/specs/app-launch.spec.ts
Normal file
49
tests/e2e/specs/app-launch.spec.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { expect, test } from '../fixtures/electron.fixture'
|
||||
import { waitForAppReady } from '../utils/wait-helpers'
|
||||
|
||||
test.describe('App Launch', () => {
|
||||
test('should launch the application successfully', async ({ mainWindow }) => {
|
||||
await waitForAppReady(mainWindow)
|
||||
expect(mainWindow).toBeDefined()
|
||||
|
||||
const title = await mainWindow.title()
|
||||
expect(title).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should display the main content', async ({ mainWindow }) => {
|
||||
await waitForAppReady(mainWindow)
|
||||
|
||||
// Check for main app content
|
||||
const hasContent = await mainWindow.evaluate(() => {
|
||||
const root = document.querySelector('#root')
|
||||
return root !== null && root.innerHTML.length > 100
|
||||
})
|
||||
|
||||
expect(hasContent).toBe(true)
|
||||
})
|
||||
|
||||
test('should have React root mounted', async ({ mainWindow }) => {
|
||||
await waitForAppReady(mainWindow)
|
||||
|
||||
const hasReactRoot = await mainWindow.evaluate(() => {
|
||||
const root = document.querySelector('#root')
|
||||
return root !== null && root.children.length > 0
|
||||
})
|
||||
|
||||
expect(hasReactRoot).toBe(true)
|
||||
})
|
||||
|
||||
test('should have window with reasonable size', async ({ electronApp, mainWindow }) => {
|
||||
await waitForAppReady(mainWindow)
|
||||
|
||||
const bounds = await electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
return win?.getBounds()
|
||||
})
|
||||
|
||||
expect(bounds).toBeDefined()
|
||||
// Window should have some reasonable size (may vary based on saved state)
|
||||
expect(bounds!.width).toBeGreaterThan(400)
|
||||
expect(bounds!.height).toBeGreaterThan(300)
|
||||
})
|
||||
})
|
||||
35
tests/e2e/specs/conversation/basic-chat.spec.ts
Normal file
35
tests/e2e/specs/conversation/basic-chat.spec.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { expect, test } from '../../fixtures/electron.fixture'
|
||||
import { waitForAppReady } from '../../utils/wait-helpers'
|
||||
|
||||
test.describe('Basic Chat', () => {
|
||||
test.beforeEach(async ({ mainWindow }) => {
|
||||
await waitForAppReady(mainWindow)
|
||||
})
|
||||
|
||||
test('should display main content on home page', async ({ mainWindow }) => {
|
||||
// Home page is the default, just verify content exists
|
||||
const hasContent = await mainWindow.evaluate(() => {
|
||||
const root = document.querySelector('#root')
|
||||
return root !== null && root.innerHTML.length > 100
|
||||
})
|
||||
|
||||
expect(hasContent).toBe(true)
|
||||
})
|
||||
|
||||
test('should have input area for chat', async ({ mainWindow }) => {
|
||||
// Look for textarea or input elements that could be chat input
|
||||
const inputElements = mainWindow.locator('textarea, [contenteditable="true"], input[type="text"]')
|
||||
const count = await inputElements.count()
|
||||
|
||||
// There should be at least one input element
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('should have interactive elements', async ({ mainWindow }) => {
|
||||
// Check for buttons or clickable elements
|
||||
const buttons = mainWindow.locator('button')
|
||||
const count = await buttons.count()
|
||||
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
46
tests/e2e/specs/navigation.spec.ts
Normal file
46
tests/e2e/specs/navigation.spec.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { expect, test } from '../fixtures/electron.fixture'
|
||||
import { SidebarPage } from '../pages/sidebar.page'
|
||||
import { waitForAppReady } from '../utils/wait-helpers'
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
let sidebarPage: SidebarPage
|
||||
|
||||
test.beforeEach(async ({ mainWindow }) => {
|
||||
await waitForAppReady(mainWindow)
|
||||
sidebarPage = new SidebarPage(mainWindow)
|
||||
})
|
||||
|
||||
test('should navigate to Settings page', async ({ mainWindow }) => {
|
||||
await sidebarPage.goToSettings()
|
||||
|
||||
// Wait a bit for navigation to complete
|
||||
await mainWindow.waitForTimeout(1000)
|
||||
|
||||
const currentUrl = mainWindow.url()
|
||||
expect(currentUrl).toContain('/settings')
|
||||
})
|
||||
|
||||
test('should navigate to Files page', async ({ mainWindow }) => {
|
||||
await sidebarPage.goToFiles()
|
||||
|
||||
await mainWindow.waitForTimeout(1000)
|
||||
|
||||
const currentUrl = mainWindow.url()
|
||||
expect(currentUrl).toContain('/files')
|
||||
})
|
||||
|
||||
test('should navigate back to Home', async ({ mainWindow }) => {
|
||||
// First go to settings
|
||||
await sidebarPage.goToSettings()
|
||||
await mainWindow.waitForTimeout(1000)
|
||||
|
||||
// Then go back to home
|
||||
await sidebarPage.goToHome()
|
||||
await mainWindow.waitForTimeout(1000)
|
||||
|
||||
// Verify we're on home page
|
||||
const currentUrl = mainWindow.url()
|
||||
// Home page URL should be either / or empty hash
|
||||
expect(currentUrl).toMatch(/#\/?$|#$/)
|
||||
})
|
||||
})
|
||||
55
tests/e2e/specs/settings/general.spec.ts
Normal file
55
tests/e2e/specs/settings/general.spec.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { expect, test } from '../../fixtures/electron.fixture'
|
||||
import { SettingsPage } from '../../pages/settings.page'
|
||||
import { SidebarPage } from '../../pages/sidebar.page'
|
||||
import { waitForAppReady } from '../../utils/wait-helpers'
|
||||
|
||||
test.describe('Settings Page', () => {
|
||||
let settingsPage: SettingsPage
|
||||
let sidebarPage: SidebarPage
|
||||
|
||||
test.beforeEach(async ({ mainWindow }) => {
|
||||
await waitForAppReady(mainWindow)
|
||||
sidebarPage = new SidebarPage(mainWindow)
|
||||
settingsPage = new SettingsPage(mainWindow)
|
||||
|
||||
// Navigate to settings
|
||||
await sidebarPage.goToSettings()
|
||||
await mainWindow.waitForTimeout(1000)
|
||||
})
|
||||
|
||||
test('should display settings page', async ({ mainWindow }) => {
|
||||
const currentUrl = mainWindow.url()
|
||||
expect(currentUrl).toContain('/settings')
|
||||
})
|
||||
|
||||
test('should have settings menu items', async ({ mainWindow }) => {
|
||||
// Check for settings menu items by looking for links
|
||||
const menuItems = mainWindow.locator('a[href*="/settings/"]')
|
||||
const count = await menuItems.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('should navigate to General settings', async ({ mainWindow }) => {
|
||||
await settingsPage.goToGeneral()
|
||||
await mainWindow.waitForTimeout(500)
|
||||
|
||||
const currentUrl = mainWindow.url()
|
||||
expect(currentUrl).toContain('/settings/general')
|
||||
})
|
||||
|
||||
test('should navigate to Display settings', async ({ mainWindow }) => {
|
||||
await settingsPage.goToDisplay()
|
||||
await mainWindow.waitForTimeout(500)
|
||||
|
||||
const currentUrl = mainWindow.url()
|
||||
expect(currentUrl).toContain('/settings/display')
|
||||
})
|
||||
|
||||
test('should navigate to About page', async ({ mainWindow }) => {
|
||||
await settingsPage.goToAbout()
|
||||
await mainWindow.waitForTimeout(500)
|
||||
|
||||
const currentUrl = mainWindow.url()
|
||||
expect(currentUrl).toContain('/settings/about')
|
||||
})
|
||||
})
|
||||
4
tests/e2e/utils/index.ts
Normal file
4
tests/e2e/utils/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Export all utilities for easy importing.
|
||||
*/
|
||||
export * from './wait-helpers'
|
||||
103
tests/e2e/utils/wait-helpers.ts
Normal file
103
tests/e2e/utils/wait-helpers.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Wait for the application to be fully ready.
|
||||
* The app uses PersistGate which may delay initial render.
|
||||
* Layout can be either Sidebar-based or TabsContainer-based depending on settings.
|
||||
*/
|
||||
export async function waitForAppReady(page: Page, timeout: number = 60000): Promise<void> {
|
||||
// First, wait for React root to be attached
|
||||
await page.waitForSelector('#root', { state: 'attached', timeout })
|
||||
|
||||
// Wait for main app content to render
|
||||
// The app may show either:
|
||||
// 1. Sidebar layout (navbarPosition === 'left')
|
||||
// 2. TabsContainer layout (default)
|
||||
// 3. Home page content
|
||||
await page.waitForSelector(
|
||||
[
|
||||
'#home-page', // Home page container
|
||||
'[class*="Sidebar"]', // Sidebar component
|
||||
'[class*="TabsContainer"]', // Tabs container
|
||||
'[class*="home-navbar"]', // Home navbar
|
||||
'[class*="Container"]' // Generic container from styled-components
|
||||
].join(', '),
|
||||
{
|
||||
state: 'visible',
|
||||
timeout
|
||||
}
|
||||
)
|
||||
|
||||
// Additional wait for React to fully hydrate
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for navigation to a specific path.
|
||||
* The app uses HashRouter, so paths are prefixed with #.
|
||||
*/
|
||||
export async function waitForNavigation(page: Page, path: string, timeout: number = 15000): Promise<void> {
|
||||
await page.waitForURL(`**/#${path}**`, { timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the chat interface to be ready.
|
||||
*/
|
||||
export async function waitForChatReady(page: Page, timeout: number = 30000): Promise<void> {
|
||||
await page.waitForSelector(
|
||||
['#home-page', '[class*="Chat"]', '[class*="Inputbar"]', '[class*="home-tabs"]'].join(', '),
|
||||
{ state: 'visible', timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the settings page to load.
|
||||
*/
|
||||
export async function waitForSettingsLoad(page: Page, timeout: number = 30000): Promise<void> {
|
||||
await page.waitForSelector(['[class*="SettingsPage"]', '[class*="Settings"]', 'a[href*="/settings/"]'].join(', '), {
|
||||
state: 'visible',
|
||||
timeout
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a modal/dialog to appear.
|
||||
*/
|
||||
export async function waitForModal(page: Page, timeout: number = 10000): Promise<void> {
|
||||
await page.waitForSelector('.ant-modal, [role="dialog"], .ant-drawer', { state: 'visible', timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a modal/dialog to close.
|
||||
*/
|
||||
export async function waitForModalClose(page: Page, timeout: number = 10000): Promise<void> {
|
||||
await page.waitForSelector('.ant-modal, [role="dialog"], .ant-drawer', { state: 'hidden', timeout })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for loading state to complete.
|
||||
*/
|
||||
export async function waitForLoadingComplete(page: Page, timeout: number = 30000): Promise<void> {
|
||||
const spinner = page.locator('.ant-spin, [class*="Loading"], [class*="Spinner"]')
|
||||
if ((await spinner.count()) > 0) {
|
||||
await spinner.first().waitFor({ state: 'hidden', timeout })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a notification/toast to appear.
|
||||
*/
|
||||
export async function waitForNotification(page: Page, timeout: number = 10000): Promise<void> {
|
||||
await page.waitForSelector('.ant-notification, .ant-message, [class*="Notification"]', {
|
||||
state: 'visible',
|
||||
timeout
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for a specified duration.
|
||||
* Use sparingly - prefer explicit waits when possible.
|
||||
*/
|
||||
export async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
55
yarn.lock
55
yarn.lock
@ -5437,14 +5437,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@playwright/test@npm:^1.52.0":
|
||||
version: 1.52.0
|
||||
resolution: "@playwright/test@npm:1.52.0"
|
||||
"@playwright/test@npm:^1.55.1":
|
||||
version: 1.57.0
|
||||
resolution: "@playwright/test@npm:1.57.0"
|
||||
dependencies:
|
||||
playwright: "npm:1.52.0"
|
||||
playwright: "npm:1.57.0"
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10c0/1c428b421593eb4f79b7c99783a389c3ab3526c9051ec772749f4fca61414dfa9f2344eba846faac5f238084aa96c836364a91d81d3034ac54924f239a93e247
|
||||
checksum: 10c0/35ba4b28be72bf0a53e33dbb11c6cff848fb9a37f49e893ce63a90675b5291ec29a1ba82c8a3b043abaead129400f0589623e9ace2e6a1c8eaa409721ecc3774
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -10059,7 +10059,7 @@ __metadata:
|
||||
"@opentelemetry/sdk-trace-web": "npm:^2.0.0"
|
||||
"@opeoginni/github-copilot-openai-compatible": "npm:^0.1.21"
|
||||
"@paymoapp/electron-shutdown-handler": "npm:^1.1.2"
|
||||
"@playwright/test": "npm:^1.52.0"
|
||||
"@playwright/test": "npm:^1.55.1"
|
||||
"@radix-ui/react-context-menu": "npm:^2.2.16"
|
||||
"@reduxjs/toolkit": "npm:^2.2.5"
|
||||
"@shikijs/markdown-it": "npm:^3.12.0"
|
||||
@ -10219,7 +10219,6 @@ __metadata:
|
||||
p-queue: "npm:^8.1.0"
|
||||
pdf-lib: "npm:^1.17.1"
|
||||
pdf-parse: "npm:^1.1.1"
|
||||
playwright: "npm:^1.55.1"
|
||||
proxy-agent: "npm:^6.5.0"
|
||||
qrcode.react: "npm:^4.2.0"
|
||||
react: "npm:^19.2.0"
|
||||
@ -20699,51 +20698,27 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright-core@npm:1.52.0":
|
||||
version: 1.52.0
|
||||
resolution: "playwright-core@npm:1.52.0"
|
||||
"playwright-core@npm:1.57.0":
|
||||
version: 1.57.0
|
||||
resolution: "playwright-core@npm:1.57.0"
|
||||
bin:
|
||||
playwright-core: cli.js
|
||||
checksum: 10c0/640945507e6ca2144e9f596b2a6ecac042c2fd3683ff99e6271e9a7b38f3602d415f282609d569456f66680aab8b3c5bb1b257d8fb63a7fc0ed648261110421f
|
||||
checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright-core@npm:1.56.1":
|
||||
version: 1.56.1
|
||||
resolution: "playwright-core@npm:1.56.1"
|
||||
bin:
|
||||
playwright-core: cli.js
|
||||
checksum: 10c0/ffd40142b99c68678b387445d5b42f1fee4ab0b65d983058c37f342e5629f9cdbdac0506ea80a0dfd41a8f9f13345bad54e9a8c35826ef66dc765f4eb3db8da7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright@npm:1.52.0":
|
||||
version: 1.52.0
|
||||
resolution: "playwright@npm:1.52.0"
|
||||
"playwright@npm:1.57.0":
|
||||
version: 1.57.0
|
||||
resolution: "playwright@npm:1.57.0"
|
||||
dependencies:
|
||||
fsevents: "npm:2.3.2"
|
||||
playwright-core: "npm:1.52.0"
|
||||
playwright-core: "npm:1.57.0"
|
||||
dependenciesMeta:
|
||||
fsevents:
|
||||
optional: true
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10c0/2c6edf1e15e59bbaf77f3fa0fe0ac975793c17cff835d9c8b8bc6395a3b6f1c01898b3058ab37891b2e4d424bcc8f1b4844fe70d943e0143d239d7451408c579
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright@npm:^1.55.1":
|
||||
version: 1.56.1
|
||||
resolution: "playwright@npm:1.56.1"
|
||||
dependencies:
|
||||
fsevents: "npm:2.3.2"
|
||||
playwright-core: "npm:1.56.1"
|
||||
dependenciesMeta:
|
||||
fsevents:
|
||||
optional: true
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10c0/8e9965aede86df0f4722063385748498977b219630a40a10d1b82b8bd8d4d4e9b6b65ecbfa024331a30800163161aca292fb6dd7446c531a1ad25f4155625ab4
|
||||
checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user