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