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>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
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): Promise<void> {
|
||||
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||
await page.locator('.dialog-content input').first().fill('拖拽测试')
|
||||
await page.getByRole('button', { name: '创建' }).click()
|
||||
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
async function ensureChapterEditor(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function dragChapterItem(page: Page, draggedId: string, targetId: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ draggedId, targetId }) => {
|
||||
const source = document.querySelector(`[data-testid="chapter-item-${draggedId}"]`)
|
||||
const target = document.querySelector(`[data-testid="chapter-item-${targetId}"]`)
|
||||
if (!source || !target) throw new Error('chapter elements not found')
|
||||
const dt = new DataTransfer()
|
||||
dt.setData('application/x-bilin-chapter', draggedId)
|
||||
source.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt }))
|
||||
target.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
target.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }))
|
||||
},
|
||||
{ draggedId, targetId }
|
||||
)
|
||||
}
|
||||
|
||||
async function chapterIds(page: Page): Promise<string[]> {
|
||||
return page.locator('.chapter-item').evaluateAll((els) =>
|
||||
els.map((el) => el.getAttribute('data-testid')?.replace('chapter-item-', '') ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('P2.1 features', () => {
|
||||
let userDataDir: string
|
||||
|
||||
test.beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-p21-'))
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
if (userDataDir && existsSync(userDataDir)) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('E2E-P21-DRAG-01: drag chapter reorders list and persists', async () => {
|
||||
const app1 = await launchApp(userDataDir)
|
||||
const page1 = await app1.firstWindow()
|
||||
await skipOnboarding(page1)
|
||||
await createAndOpenBook(page1)
|
||||
await page1.getByRole('button', { name: '+ 新章' }).click()
|
||||
await page1.getByRole('button', { name: '+ 新章' }).click()
|
||||
|
||||
const items = page1.locator('.chapter-item')
|
||||
await expect(items).toHaveCount(2, { timeout: 10_000 })
|
||||
const idsBefore = await chapterIds(page1)
|
||||
await dragChapterItem(page1, idsBefore[1], idsBefore[0])
|
||||
|
||||
await expect.poll(async () => (await chapterIds(page1)).join(',')).not.toBe(idsBefore.join(','))
|
||||
|
||||
const idsAfter = await chapterIds(page1)
|
||||
await app1.close()
|
||||
|
||||
const app2 = await launchApp(userDataDir)
|
||||
const page2 = await app2.firstWindow()
|
||||
await page2.getByText('拖拽测试').click()
|
||||
await expect(page2.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||
const titlesRestart = await chapterIds(page2)
|
||||
expect(titlesRestart).toEqual(idsAfter)
|
||||
await app2.close()
|
||||
})
|
||||
|
||||
test('E2E-P21-REP-01: search replace preview and execute updates editor', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
page.on('dialog', (dialog) => void dialog.accept())
|
||||
await skipOnboarding(page)
|
||||
await createAndOpenBook(page)
|
||||
await ensureChapterEditor(page)
|
||||
|
||||
const oldWord = '替换专用词OLD'
|
||||
const newWord = '替换专用词NEW'
|
||||
const editor = page.locator('.ProseMirror')
|
||||
await editor.click()
|
||||
await editor.pressSequentially(oldWord)
|
||||
await page.keyboard.press('Control+S')
|
||||
await expect(page.getByText('已保存')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('bilin:e2e-open-search')))
|
||||
await expect(page.getByTestId('search-modal')).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByTestId('search-input').fill(oldWord)
|
||||
await expect(page.locator('.search-result').first()).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.getByTestId('search-replace-panel').locator('summary').click()
|
||||
await page.getByTestId('search-replace-input').fill(newWord)
|
||||
await page.getByTestId('search-replace-preview').click()
|
||||
await expect(page.getByTestId('search-replace-previews')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.getByTestId('search-replace-execute').click()
|
||||
await expect(page.getByText(/已替换/)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(editor).toContainText(newWord, { timeout: 10_000 })
|
||||
await expect(editor).not.toContainText(oldWord)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('E2E-P21-INSP-01: capture inspiration shortcut opens modal and saves', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
await createAndOpenBook(page)
|
||||
await ensureChapterEditor(page)
|
||||
|
||||
await page.keyboard.press('Control+Shift+I')
|
||||
const modal = page.getByTestId('inspiration-modal')
|
||||
if (!(await modal.isVisible().catch(() => false))) {
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('bilin:e2e-capture-inspiration')))
|
||||
}
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
const idea = '灵感捕获测试内容ABC'
|
||||
await page.getByTestId('inspiration-content-input').fill(idea)
|
||||
await page.getByTestId('inspiration-save-btn').click()
|
||||
await expect(page.getByTestId('inspiration-modal')).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
await expect(page.getByTestId('inspiration-list')).toBeVisible()
|
||||
await expect(page.getByTestId('inspiration-list').getByText(idea)).toBeVisible({ timeout: 10_000 })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user