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:
@@ -11,6 +11,8 @@ logs/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
release/
|
||||
!tests/release/
|
||||
!tests/release/**
|
||||
*.novel
|
||||
*.novel-all
|
||||
test-results/
|
||||
|
||||
@@ -76,8 +76,10 @@ npm run dev
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run test # 单元 / 集成测试
|
||||
npm run test:e2e # E2E 测试(需先 build)
|
||||
npm run test:release # 发布前快速门禁:build + 导入/产物检查 + 启动冒烟 E2E
|
||||
npm run test # 全量单元/集成(含 LM Studio 十章小说 IT,耗时长)
|
||||
npm run test:ci # 完整 CI:build + 全量 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` 覆盖)。
|
||||
|
||||
@@ -136,7 +136,7 @@ test.describe('Cockpit & Knowledge P5', () => {
|
||||
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 page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
@@ -150,6 +150,9 @@ test.describe('Cockpit & Knowledge P5', () => {
|
||||
await dismissCockpitIfOpen(page)
|
||||
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||
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-previous-tail"]')).toBeVisible({ timeout: 60_000 })
|
||||
await expect(page.locator('[data-testid="bridge-previous-tail"]')).not.toHaveText('(无)')
|
||||
@@ -187,6 +190,9 @@ test.describe('Cockpit & Knowledge P5', () => {
|
||||
await dismissCockpitIfOpen(page)
|
||||
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||
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-ai-suggestion"]')).not.toBeEmpty({ timeout: 240_000 })
|
||||
await app.close()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,8 @@
|
||||
"preview": "npx electron-vite preview",
|
||||
"test": "node node_modules/vitest/vitest.mjs run",
|
||||
"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"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { applySlashCommand } from '@renderer/lib/ai-slash-commands'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { insertToEditor } from '@renderer/lib/editor-commands'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||
|
||||
@@ -200,16 +200,6 @@ export function EditorLayout(): React.JSX.Element {
|
||||
if (bridgeChapterId) setBridgeOpen(true)
|
||||
}, [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 =
|
||||
bridgeChapterId ?? (target?.kind === 'chapter' ? target.id : selectedChapterId)
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user