Files
bilin/e2e/ai-chat.spec.ts
T
bing 8ce35a1e8e feat: ship v0.3.0 with P2.1 editor polish and P3 AI collaboration
Add chapter reorder, search replace, inspiration capture, and AI chat with context editing, settings, offline handling, and naming checks. Fix dev black screen by keeping process.env reads in the main process only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 16:33:56 +08:00

205 lines
7.8 KiB
TypeScript

import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect, _electron as electron, type Page } from '@playwright/test'
const ROOT = path.join(import.meta.dirname, '..')
async function launchApp(userDataDir: string) {
return electron.launch({
args: [ROOT],
env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir }
})
}
async function skipOnboarding(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
if (await page.getByText('欢迎使用笔临').isVisible()) {
await page.getByRole('button', { name: '跳过' }).click()
}
}
async function createAndOpenBook(page: Page, name = 'AI测试书'): Promise<void> {
await page.getByRole('button', { name: /新建书籍/ }).first().click()
await page.locator('.dialog-content input').first().fill(name)
await page.getByRole('button', { name: '创建' }).click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
}
async function openGlobalSettings(page: Page): Promise<void> {
const topSettings = page.locator('#topbar button.top-btn').filter({ hasText: '⚙' })
if (await topSettings.isVisible()) {
await topSettings.click()
} else {
await page.getByRole('button', { name: /系统设置/ }).click()
}
await expect(page.locator('#settings-page')).toBeVisible({ timeout: 10_000 })
}
async function seedNamingConflict(page: Page): Promise<void> {
await page.getByTestId('sidebar-tab-setting').click()
await page.getByTestId('setting-new').click()
await page.getByTestId('setting-name-input').fill('林远')
await page.locator('.dialog-content select').selectOption('character')
await page.getByRole('button', { name: '创建' }).click()
const settingItem = page.getByTestId(/setting-item-/).first()
await expect(settingItem).toBeVisible({ timeout: 10_000 })
const bookId = await page.locator('#editor-layout').getAttribute('data-book-id')
const settingTestId = await settingItem.getAttribute('data-testid')
const settingId = settingTestId?.replace('setting-item-', '') ?? ''
expect(bookId).toBeTruthy()
expect(settingId).toBeTruthy()
await page.evaluate(
async ({ bookId, settingId }) => {
const result = await window.electronAPI.setting.update(bookId, settingId, {
description: '<p>正文中出现了林悦这一名字。</p>'
})
if (!result.ok) throw new Error(result.error.message)
},
{ bookId: bookId!, settingId }
)
const saved = await page.evaluate(async (bookId) => {
const result = await window.electronAPI.setting.list(bookId)
if (!result.ok) return ''
return result.data.find((s) => s.name === '林远')?.description ?? ''
}, bookId!)
expect(saved).toContain('林悦')
}
test.describe('AI chat', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-ai-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-P3-CHAT-01: send message and receive assistant reply', async () => {
test.setTimeout(120_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page)
await page.getByTestId('panel-tab-ai').click()
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('ai-session-new').click()
await expect(page.getByTestId('ai-session-select')).not.toHaveValue('', { timeout: 10_000 })
await page.getByTestId('ai-chat-input').fill('你好')
await page.getByTestId('ai-send').click()
const assistant = page.getByTestId('chat-message-assistant').last()
await expect(assistant).toBeVisible({ timeout: 90_000 })
await expect(assistant).not.toBeEmpty()
await app.close()
})
test('E2E-P3-CTX-01: select setting updates context summary', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page)
await page.getByTestId('sidebar-tab-setting').click()
await page.getByTestId('setting-new').click()
await page.getByTestId('setting-name-input').fill('主角林远')
await page.locator('.dialog-content select').selectOption('character')
await page.getByRole('button', { name: '创建' }).click()
await expect(page.getByTestId(/setting-item-/)).toBeVisible({ timeout: 10_000 })
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await page.getByTestId('ai-context-preview').click()
await expect(page.getByTestId('context-editor-modal')).toBeVisible()
await page.getByTestId('context-tab-setting').click()
const settingItem = page.locator('[data-testid^="context-item-"]').first()
await settingItem.locator('input').check()
await page.getByTestId('context-save').click()
await expect(page.getByTestId('context-editor-modal')).toBeHidden()
await expect(page.getByTestId('ai-context-preview')).toContainText('设定1个', { timeout: 10_000 })
await app.close()
})
test('E2E-P3-CMD-01: slash summarize command sends and receives reply', async () => {
test.setTimeout(120_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page)
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await page.getByTestId('ai-quick-cmd-总结').click()
await expect(page.getByTestId('ai-send')).toBeEnabled()
await page.getByTestId('ai-send').click()
await expect(page.getByTestId('chat-message-user').last()).toContainText('/总结', { timeout: 90_000 })
const assistant = page.getByTestId('chat-message-assistant').last()
await expect(assistant).toBeVisible({ timeout: 90_000 })
await app.close()
})
test('E2E-P3-NAME-01: naming check finds conflict between setting and chapter', async () => {
test.setTimeout(180_000)
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page)
await seedNamingConflict(page)
await page.getByTestId('panel-tab-knowledge').click()
await page.getByTestId('naming-check-open').click()
await expect(page.getByTestId('naming-check-modal')).toBeVisible()
await page.getByTestId('naming-run-check').click()
const row = page.getByTestId('naming-row').first()
await expect(row).toBeVisible({ timeout: 30_000 })
await expect(row).toContainText('林远')
await expect(row).toContainText('林悦')
await app.close()
})
test('E2E-P3-OFF-01: cloud backend disables AI input when offline', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await createAndOpenBook(page)
await openGlobalSettings(page)
await page.getByTestId('settings-nav-ai').click()
await page.getByTestId('ai-settings-backend').selectOption('openai')
await page.getByText('AI测试书').click()
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 10_000 })
await page.getByTestId('panel-tab-ai').click()
await page.getByTestId('ai-session-new').click()
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('bilin:e2e-set-ai-online', { detail: { online: false } }))
})
await expect(page.getByTestId('ai-offline-banner')).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('ai-chat-input')).toBeDisabled()
await expect(page.getByTestId('ai-send')).toBeDisabled()
await app.close()
})
})