Merge remote-tracking branch 'origin/main' into feat/proxy-api-server

This commit is contained in:
suyao 2025-11-27 21:34:24 +08:00
commit 5d1d2b7a9b
No known key found for this signature in database
30 changed files with 1836 additions and 273 deletions

View File

@ -11,6 +11,7 @@
"dist/**",
"out/**",
"local/**",
"tests/**",
".yarn/**",
".gitignore",
"scripts/cloudflare-worker.js",

View File

@ -58,6 +58,7 @@ export default defineConfig([
'dist/**',
'out/**',
'local/**',
'tests/**',
'.yarn/**',
'.gitignore',
'scripts/cloudflare-worker.js',

View File

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

View File

@ -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,
// },
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View 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)
// 检查 URLHashRouter
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`: 并发数 (1Electron 需要串行)
- `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)

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

View 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

View File

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

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

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

View 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
View 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'

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

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

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

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

View 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(/#\/?$|#$/)
})
})

View 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
View File

@ -0,0 +1,4 @@
/**
* Export all utilities for easy importing.
*/
export * from './wait-helpers'

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

View File

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