From 958bb18539552d6d9b014f8f2a5d288c9f9005af Mon Sep 17 00:00:00 2001 From: kun1h Date: Thu, 9 Jul 2026 19:09:18 +0800 Subject: [PATCH] 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 --- .gitignore | 2 + README.md | 6 +- e2e/cockpit-knowledge.spec.ts | 8 ++- e2e/release-smoke.spec.ts | 55 ++++++++++++++++++ package.json | 2 + src/renderer/components/ai/AiPanel.tsx | 1 - .../components/layout/EditorLayout.tsx | 10 ---- tests/release/build-artifacts.test.ts | 18 ++++++ tests/release/import-hygiene.test.ts | 58 +++++++++++++++++++ 9 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 e2e/release-smoke.spec.ts create mode 100644 tests/release/build-artifacts.test.ts create mode 100644 tests/release/import-hygiene.test.ts diff --git a/.gitignore b/.gitignore index d69041e..1ef48db 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ logs/ .DS_Store Thumbs.db release/ +!tests/release/ +!tests/release/** *.novel *.novel-all test-results/ diff --git a/README.md b/README.md index cfb73ea..88a269e 100644 --- a/README.md +++ b/README.md @@ -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` 覆盖)。 diff --git a/e2e/cockpit-knowledge.spec.ts b/e2e/cockpit-knowledge.spec.ts index 6a43f38..c56140f 100644 --- a/e2e/cockpit-knowledge.spec.ts +++ b/e2e/cockpit-knowledge.spec.ts @@ -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() diff --git a/e2e/release-smoke.spec.ts b/e2e/release-smoke.spec.ts new file mode 100644 index 0000000..680400c --- /dev/null +++ b/e2e/release-smoke.spec.ts @@ -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() + }) +}) diff --git a/package.json b/package.json index efc90ea..a76178e 100644 --- a/package.json +++ b/package.json @@ -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": [], diff --git a/src/renderer/components/ai/AiPanel.tsx b/src/renderer/components/ai/AiPanel.tsx index e703a9e..3a89980 100644 --- a/src/renderer/components/ai/AiPanel.tsx +++ b/src/renderer/components/ai/AiPanel.tsx @@ -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' diff --git a/src/renderer/components/layout/EditorLayout.tsx b/src/renderer/components/layout/EditorLayout.tsx index e100d00..86ff7e1 100644 --- a/src/renderer/components/layout/EditorLayout.tsx +++ b/src/renderer/components/layout/EditorLayout.tsx @@ -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) diff --git a/tests/release/build-artifacts.test.ts b/tests/release/build-artifacts.test.ts new file mode 100644 index 0000000..7530cb2 --- /dev/null +++ b/tests/release/build-artifacts.test.ts @@ -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([]) + }) +}) diff --git a/tests/release/import-hygiene.test.ts b/tests/release/import-hygiene.test.ts new file mode 100644 index 0000000..fe778a3 --- /dev/null +++ b/tests/release/import-hygiene.test.ts @@ -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>() + 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([]) + }) +})