fix(ui): make chapter bridge manual-only and add release gate

Remove auto chapter-bridge prompt on book/chapter open so users trigger it from the cockpit. Fix AiPanel duplicate import that broke dev builds, and add release smoke tests plus test:release/test:ci scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-09 19:09:18 +08:00
parent 8e5045b882
commit 958bb18539
9 changed files with 146 additions and 14 deletions
+2
View File
@@ -11,6 +11,8 @@ logs/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
release/ release/
!tests/release/
!tests/release/**
*.novel *.novel
*.novel-all *.novel-all
test-results/ test-results/
+4 -2
View File
@@ -76,8 +76,10 @@ npm run dev
```bash ```bash
npm run build npm run build
npm run test # 单元 / 集成测试 npm run test:release # 发布前快速门禁:build + 导入/产物检查 + 启动冒烟 E2E
npm run test:e2e # E2E 测试(需先 build npm run test # 全量单元/集成(含 LM Studio 十章小说 IT,耗时长
npm run test:ci # 完整 CIbuild + 全量 UT/IT + 全量 E2E
npm run test:e2e # 仅 E2E(需先 build
``` ```
**AI 相关测试前置条件**:本地 [LM Studio](http://127.0.0.1:1234) 需运行,模型默认 `gemma-4-e4b-it`(可通过环境变量 `BILIN_AI_MODEL` / `BILIN_AI_BASE_URL` 覆盖)。 **AI 相关测试前置条件**:本地 [LM Studio](http://127.0.0.1:1234) 需运行,模型默认 `gemma-4-e4b-it`(可通过环境变量 `BILIN_AI_MODEL` / `BILIN_AI_BASE_URL` 覆盖)。
+7 -1
View File
@@ -136,7 +136,7 @@ test.describe('Cockpit & Knowledge P5', () => {
await app.close() await app.close()
}) })
test('E2E-BRIDGE-01: new chapter triggers bridge modal', async () => { test('E2E-BRIDGE-01: bridge modal opens from cockpit manually', async () => {
const app = await launchApp(userDataDir) const app = await launchApp(userDataDir)
const page = await app.firstWindow() const page = await app.firstWindow()
await skipOnboarding(page) await skipOnboarding(page)
@@ -150,6 +150,9 @@ test.describe('Cockpit & Knowledge P5', () => {
await dismissCockpitIfOpen(page) await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click() await page.getByRole('button', { name: '+ 新章' }).click()
await dismissCockpitIfOpen(page) await dismissCockpitIfOpen(page)
await expect(page.locator('[data-testid="bridge-modal"]')).toBeHidden({ timeout: 3000 })
await page.locator('[data-testid="topbar-cockpit"]').click()
await page.locator('[data-testid="cockpit-bridge-check"]').click()
await expect(page.locator('[data-testid="bridge-modal"]')).toBeVisible({ timeout: 10_000 }) await expect(page.locator('[data-testid="bridge-modal"]')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('[data-testid="bridge-previous-tail"]')).toBeVisible({ timeout: 60_000 }) await expect(page.locator('[data-testid="bridge-previous-tail"]')).toBeVisible({ timeout: 60_000 })
await expect(page.locator('[data-testid="bridge-previous-tail"]')).not.toHaveText('(无)') await expect(page.locator('[data-testid="bridge-previous-tail"]')).not.toHaveText('(无)')
@@ -187,6 +190,9 @@ test.describe('Cockpit & Knowledge P5', () => {
await dismissCockpitIfOpen(page) await dismissCockpitIfOpen(page)
await page.getByRole('button', { name: '+ 新章' }).click() await page.getByRole('button', { name: '+ 新章' }).click()
await dismissCockpitIfOpen(page) await dismissCockpitIfOpen(page)
await expect(page.locator('[data-testid="bridge-modal"]')).toBeHidden({ timeout: 3000 })
await page.locator('[data-testid="topbar-cockpit"]').click()
await page.locator('[data-testid="cockpit-bridge-check"]').click()
await expect(page.locator('[data-testid="bridge-modal"]')).toBeVisible({ timeout: 15_000 }) await expect(page.locator('[data-testid="bridge-modal"]')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('[data-testid="bridge-ai-suggestion"]')).not.toBeEmpty({ timeout: 240_000 }) await expect(page.locator('[data-testid="bridge-ai-suggestion"]')).not.toBeEmpty({ timeout: 240_000 })
await app.close() await app.close()
+55
View File
@@ -0,0 +1,55 @@
import path from 'path'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { test, expect } from '@playwright/test'
import { launchApp, skipOnboarding } from './helpers'
const ROOT = path.join(import.meta.dirname, '..')
test.describe('Release smoke', () => {
let userDataDir: string
test.beforeEach(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-release-'))
})
test.afterEach(() => {
if (userDataDir && existsSync(userDataDir)) {
try {
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
} catch {
/* ignore */
}
}
})
test('E2E-RELEASE-01: app launches and shows home after onboarding skip', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
await expect(page.getByRole('button', { name: /新建书籍/ }).first()).toBeVisible({
timeout: 10_000
})
await app.close()
})
test('E2E-RELEASE-02: create book opens editor without cockpit blocking', async () => {
const app = await launchApp(userDataDir)
const page = await app.firstWindow()
await skipOnboarding(page)
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 })
const cockpit = page.locator('[data-testid="cockpit-modal"]')
if (await cockpit.isVisible({ timeout: 5000 }).catch(() => false)) {
await cockpit.locator('.modal-close').click()
await expect(cockpit).toBeHidden({ timeout: 5000 })
}
await page.getByRole('button', { name: '+ 新章' }).click()
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
await app.close()
})
})
+2
View File
@@ -10,6 +10,8 @@
"preview": "npx electron-vite preview", "preview": "npx electron-vite preview",
"test": "node node_modules/vitest/vitest.mjs run", "test": "node node_modules/vitest/vitest.mjs run",
"test:watch": "node node_modules/vitest/vitest.mjs", "test:watch": "node node_modules/vitest/vitest.mjs",
"test:release": "npm run build && node node_modules/vitest/vitest.mjs run tests/release && npx playwright test e2e/release-smoke.spec.ts",
"test:ci": "npm run build && npm run test && npm run test:e2e",
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"keywords": [], "keywords": [],
-1
View File
@@ -6,7 +6,6 @@ import { useAppStore } from '@renderer/stores/useAppStore'
import { useAiStore } from '@renderer/stores/useAiStore' import { useAiStore } from '@renderer/stores/useAiStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore' import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { applySlashCommand } from '@renderer/lib/ai-slash-commands' import { applySlashCommand } from '@renderer/lib/ai-slash-commands'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { insertToEditor } from '@renderer/lib/editor-commands' import { insertToEditor } from '@renderer/lib/editor-commands'
import { ipcCall } from '@renderer/lib/ipc-client' import { ipcCall } from '@renderer/lib/ipc-client'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal' import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
@@ -200,16 +200,6 @@ export function EditorLayout(): React.JSX.Element {
if (bridgeChapterId) setBridgeOpen(true) if (bridgeChapterId) setBridgeOpen(true)
}, [bridgeChapterId]) }, [bridgeChapterId])
useEffect(() => {
if (!currentBookId || target?.kind !== 'chapter') return
const chapterId = target.id
void ipcCall(() => window.electronAPI.bridge.shouldPrompt(currentBookId, chapterId)).then(
(should) => {
if (should) setTimeout(() => setBridgeOpen(true), 500)
}
)
}, [currentBookId, target?.kind, target?.id])
const activeBridgeChapterId = const activeBridgeChapterId =
bridgeChapterId ?? (target?.kind === 'chapter' ? target.id : selectedChapterId) bridgeChapterId ?? (target?.kind === 'chapter' ? target.id : selectedChapterId)
+18
View File
@@ -0,0 +1,18 @@
import { existsSync } from 'fs'
import { join } from 'path'
import { describe, it, expect } from 'vitest'
const ROOT = join(import.meta.dirname, '../..')
const REQUIRED_ARTIFACTS = [
'out/main/index.js',
'out/preload/index.js',
'out/renderer/index.html'
]
describe('release gate: build artifacts', () => {
it('UT-RELEASE-02: production build outputs exist', () => {
const missing = REQUIRED_ARTIFACTS.filter((rel) => !existsSync(join(ROOT, rel)))
expect(missing, `Run npm run build first. Missing: ${missing.join(', ')}`).toEqual([])
})
})
+58
View File
@@ -0,0 +1,58 @@
import { readFileSync, readdirSync, statSync } from 'fs'
import { join } from 'path'
import { describe, it, expect } from 'vitest'
const ROOT = join(import.meta.dirname, '../..')
function collectSourceFiles(dir: string, acc: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const full = join(dir, name)
const st = statSync(full)
if (st.isDirectory()) {
if (name === 'node_modules' || name === 'out') continue
collectSourceFiles(full, acc)
} else if (/\.(ts|tsx)$/.test(name)) {
acc.push(full)
}
}
return acc
}
function extractNamedImports(content: string): { from: string; names: string[] }[] {
const results: { from: string; names: string[] }[] = []
const re = /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g
let m: RegExpExecArray | null
while ((m = re.exec(content)) !== null) {
const names = m[1]
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => part.split(/\s+as\s+/)[0]!.trim())
results.push({ from: m[2]!, names })
}
return results
}
describe('release gate: import hygiene', () => {
it('UT-RELEASE-01: no duplicate named imports from the same module', () => {
const files = collectSourceFiles(join(ROOT, 'src'))
const offenders: string[] = []
for (const file of files) {
const blocks = extractNamedImports(readFileSync(file, 'utf8'))
const seen = new Map<string, Set<string>>()
for (const { from, names } of blocks) {
if (!seen.has(from)) seen.set(from, new Set())
const bucket = seen.get(from)!
for (const name of names) {
if (bucket.has(name)) {
offenders.push(`${file}: duplicate import { ${name} } from '${from}'`)
}
bucket.add(name)
}
}
}
expect(offenders, offenders.join('\n')).toEqual([])
})
})