diff --git a/electron/plugins/pluginManager.test.js b/electron/plugins/pluginManager.test.js index ca46710f..175aaded 100644 --- a/electron/plugins/pluginManager.test.js +++ b/electron/plugins/pluginManager.test.js @@ -649,7 +649,8 @@ test('installs a validated local directory as disabled without copying symlinks' try { fs.symlinkSync(path.join(source, 'worker.js'), path.join(linked, 'extra-link.js'), 'file'); } catch (error) { - t.diagnostic(`Symlink creation unavailable: ${error.message}`); + t.skip(`Symlink creation unavailable: ${error.message}`); + // node:test 的 t.skip 只标记跳过、不会终止回调,必须显式 return return; } assert.equal(manager.installFromDirectory(linked).error.code, 'PLUGIN_PACKAGE_SYMLINK'); diff --git a/electron/plugins/pluginMarketplace.test.js b/electron/plugins/pluginMarketplace.test.js index f56da747..4b505536 100644 --- a/electron/plugins/pluginMarketplace.test.js +++ b/electron/plugins/pluginMarketplace.test.js @@ -1,6 +1,6 @@ 'use strict'; -const { describe, it, beforeEach } = require('node:test'); +const { describe, it, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); @@ -161,6 +161,10 @@ describe('plugin marketplace', () => { stateFilePath = path.join(statePath, 'plugins-marketplace.json'); }); + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + function makeManager() { return createPluginManager({ pluginsRoot, statePath: path.join(tmpRoot, 'plugins-state.json') }); } @@ -759,11 +763,15 @@ describe('plugin marketplace', () => { // 临时弄坏 pluginsRoot 权限,让 installFromDirectory 的 mkdirSync 同步抛异常 const rootMode = fs.statSync(pluginsRoot).mode; fs.chmodSync(pluginsRoot, 0o000); - const second = await marketplace.install({ sourceId, directoryName: 'fixture', replace: true, - expectedPluginId: 'com.example.marketplace-fixture', - expectedVersion: '1.0.0', - }); - fs.chmodSync(pluginsRoot, rootMode); + let second; + try { + second = await marketplace.install({ sourceId, directoryName: 'fixture', replace: true, + expectedPluginId: 'com.example.marketplace-fixture', + expectedVersion: '1.0.0', + }); + } finally { + fs.chmodSync(pluginsRoot, rootMode); + } assert.equal(second.success, false, '同步异常必须被外层 catch 兜住'); const listed = await pluginManager.list(); diff --git a/electron/plugins/pluginPageBridge.test.js b/electron/plugins/pluginPageBridge.test.js index e79f5048..1d7a4cd4 100644 --- a/electron/plugins/pluginPageBridge.test.js +++ b/electron/plugins/pluginPageBridge.test.js @@ -26,12 +26,13 @@ test('AI page requests require bounded prompts and cannot carry credentials', () pluginId: 'com.example.page', pageId: 'dashboard', capability: 'ai', operation: 'generate', args: { system: 'Summarize', user: 'Example repository', maxTokens: 500 }, }); - // 内容生成页会把 README 全文放进正文:160k 字符的正文要能通过, + // 内容生成页会把 README 全文放进正文:恰好 160k 字符的正文要能通过(20 前缀 + 31_996*5), // 同时仍在 1 MiB 通用字节预算之内。 - const readmeSizedPrompt = 'README (full text):\n' + '仓库说明 '.repeat(20_000); + const readmeSizedPrompt = 'README (full text):\n' + '仓库说明 '.repeat(31_996); + assert.equal(readmeSizedPrompt.length, 160_000); assert.doesNotThrow(() => validatePageCapabilityRequest({ pluginId: 'com.example.page', pageId: 'dashboard', method: 'ai.generate', - args: { system: 'Summarize', user: readmeSizedPrompt.slice(0, 160_000) }, + args: { system: 'Summarize', user: readmeSizedPrompt }, })); for (const args of [ { system: '', user: '' }, diff --git a/electron/plugins/pluginRuntime.test.js b/electron/plugins/pluginRuntime.test.js index c26a6a38..d684c88c 100644 --- a/electron/plugins/pluginRuntime.test.js +++ b/electron/plugins/pluginRuntime.test.js @@ -75,7 +75,10 @@ test('terminates a plugin runtime when a call times out', async (t) => { entryPath, pluginId: 'com.example.timeout', permissions: [], - timeoutMs: 50, + // timeoutMs 同时约束 Worker 启动与调用超时:真实 Worker 线程在负载高的 + // CI 上启动可能超过极小值,导致 activate() 阶段就抛 PLUGIN_RUNTIME_TIMEOUT + //(flaky)。取 2000ms 与其他用例一致,调用超时语义不变。 + timeoutMs: 2000, }); await runtime.activate(); diff --git a/package-lock.json b/package-lock.json index 04ae217f..f982c87f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10023,9 +10023,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "dev": true, "license": "BSD-2-Clause" }, diff --git a/server/tests/mcp/parity.test.ts b/server/tests/mcp/parity.test.ts index e1fd7552..6adfe915 100644 --- a/server/tests/mcp/parity.test.ts +++ b/server/tests/mcp/parity.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const providerMocks = vi.hoisted(() => ({ getRepository: vi.fn(), @@ -44,6 +44,12 @@ function contract(tool: { name: string; inputSchema?: Record }) } describe('backend/Electron MCP parity', () => { + // registerMcpTools 在注册时读取 getVectorAvailability(), + // 必须在每个用例前复位,避免上一个用例的覆写泄漏到后续注册。 + beforeEach(() => { + providerMocks.getVectorAvailability.mockReturnValue({ available: true }); + }); + it('keeps the same names and input property sets for vector-enabled MCP', () => { const backend = getBackendTools().map(contract); const electron = getElectronTools(true).map((tool: { name: string; inputSchema?: { properties?: Record } }) => ({ diff --git a/server/tests/mcp/routes.test.ts b/server/tests/mcp/routes.test.ts index 367f5b98..6cfa5154 100644 --- a/server/tests/mcp/routes.test.ts +++ b/server/tests/mcp/routes.test.ts @@ -21,6 +21,9 @@ async function canOpenSqlite(): Promise { } const dbAvailable = await canOpenSqlite(); +// DB-backed suites skip instead of failing when the native sqlite binding cannot +// be opened (e.g. Node 26 without a rebuildable better-sqlite3); the pure-unit +// suites below always run. const describeIfDb = dbAvailable ? describe : describe.skip; describeIfDb('MCP admin + transport auth', () => { @@ -163,9 +166,4 @@ describe('MCP pure units always run', () => { expect(timingSafeEqualString(t, t)).toBe(true); expect(timingSafeEqualString(t, t + 'x')).toBe(false); }); - - it('notes when sqlite native binding unavailable', () => { - // Environment limitation (e.g. Node 26 without rebuildable better-sqlite3) - expect(typeof dbAvailable).toBe('boolean'); - }); }); diff --git a/src/components/MarkdownRenderer.test.tsx b/src/components/MarkdownRenderer.test.tsx index 58e99501..340a7e55 100644 --- a/src/components/MarkdownRenderer.test.tsx +++ b/src/components/MarkdownRenderer.test.tsx @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { readFileSync } from 'node:fs'; -import MarkdownRenderer from '../components/MarkdownRenderer'; +import MarkdownRenderer, { MATH_PATTERN } from '../components/MarkdownRenderer'; vi.mock('../store/useAppStore', () => ({ useAppStore: vi.fn((selector) => { @@ -23,6 +22,18 @@ vi.mock('mermaid', () => ({ }, })); +// 统计数学插件加载器被动态 import 的次数(passthrough mock,不改变真实行为): +// 普通文档用例断言计数保持为 0,覆盖「effect 绕过 MATH_PATTERN 门控直接发起加载」的回归。 +const mathLoaderCalls = vi.hoisted(() => ({ remark: 0, rehype: 0 })); +vi.mock('remark-math', async (importOriginal) => { + mathLoaderCalls.remark += 1; + return importOriginal(); +}); +vi.mock('rehype-katex', async (importOriginal) => { + mathLoaderCalls.rehype += 1; + return importOriginal(); +}); + describe('MarkdownRenderer', () => { beforeEach(() => { vi.clearAllMocks(); @@ -692,9 +703,30 @@ describe('MarkdownRenderer', () => { describe('Math (KaTeX)', () => { it('uses a Safari-compatible inline math detector', () => { - const source = readFileSync('src/components/MarkdownRenderer.tsx', 'utf8'); + // 老 Safari 不支持 lookbehind:直接断言导出正则的 source, + // 避免对整个文件做文本扫描,也去掉对 cwd 的依赖。 + // 正向 (?<= 与负向 (? { + // 本用例必须在下方 display-math 用例之前运行:vitest 会缓存已解析的 mock + // 模块,display 用例触发过动态 import 后计数器不再增长。 + mathLoaderCalls.remark = 0; + mathLoaderCalls.rehype = 0; + const { container } = render(); + // 与 effect 相同的门控判断:普通文档不满足数学语法 + expect(MATH_PATTERN.test('Just $5 and text')).toBe(false); + // 排空动态导入的微任务:若 effect 绕过门控发起 import,计数器会在这里增长 + for (let i = 0; i < 10; i++) await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mathLoaderCalls.remark).toBe(0); + expect(mathLoaderCalls.rehype).toBe(0); + expect(container.querySelector('.katex')).toBeNull(); }); it('should lazily load KaTeX and render display math', async () => { @@ -703,12 +735,5 @@ describe('MarkdownRenderer', () => { expect(container.querySelector('.katex')).toBeInTheDocument(); }, { timeout: 5000 }); }, 10000); - - it('should not load math support for plain documents', async () => { - const { container } = render(); - // Give the effect a tick; no katex nodes should ever appear - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(container.querySelector('.katex')).toBeNull(); - }); }); }); diff --git a/src/components/MarkdownRenderer.tsx b/src/components/MarkdownRenderer.tsx index 7c623bb5..6617ec98 100644 --- a/src/components/MarkdownRenderer.tsx +++ b/src/components/MarkdownRenderer.tsx @@ -49,8 +49,9 @@ const BASE_REMARK_PLUGINS = [remarkGfm, remarkAlert, remarkGemoji]; const REHYPE_PLUGINS_NO_HTML: never[] = []; // Matches $$display$$, \(inline\), \[display\] and $inline$ math so KaTeX is -// only loaded for documents that actually use it. -const MATH_PATTERN = +// only loaded for documents that actually use it. Exported so tests can assert +// the lazy-load gate directly (and keep the regex lookbehind-free for Safari). +export const MATH_PATTERN = /\$\$[\s\S]+?\$\$|\\\[[\s\S]+?\\\]|\\\([\s\S]+?\\\)|\$(?!\s)(?:\\.|[^$\\\n])*?[^\s$\\\n]\$/; interface MathPlugins { diff --git a/src/components/ReleaseCard.test.tsx b/src/components/ReleaseCard.test.tsx index 72e9c049..a1b07222 100644 --- a/src/components/ReleaseCard.test.tsx +++ b/src/components/ReleaseCard.test.tsx @@ -84,6 +84,8 @@ const renderCard = (props: Partial[0]> = {}) => { describe('ReleaseCard asset updated indicator', () => { beforeEach(() => { vi.clearAllMocks(); + // 模块级 store 状态被所有用例共享:英文用例设置的 en 不得泄漏到后续用例 + storeState.language = 'zh'; }); it('shows container-level and per-asset indicators from the same source (updated_asset_ids)', () => { diff --git a/src/components/RepositoryReleaseSheet.test.tsx b/src/components/RepositoryReleaseSheet.test.tsx index 0e67e013..e850436c 100644 --- a/src/components/RepositoryReleaseSheet.test.tsx +++ b/src/components/RepositoryReleaseSheet.test.tsx @@ -89,8 +89,8 @@ const renderSheet = () => render( /> ); -// 侧栏现在还会渲染 Repository Health 事实面板,面板里的「最新稳定版本」同样是 tag 名ã€? -// 因此针对 Release 条目的查询必须限定在 Release 列表容器内,避免与事实面板串台ã€? +// 侧栏现在还会渲染 Repository Health 事实面板,面板里的「最新稳定版本」同样是 tag 名, +// 因此针对 Release 条目的查询必须限定在 Release 列表容器内,避免与事实面板串台。 const releaseList = () => within(screen.getByTestId('release-list')); describe('RepositoryReleaseSheet', () => { @@ -159,15 +159,15 @@ describe('RepositoryReleaseSheet', () => { await user.click(releaseList().getByText('v1').closest('button')!); - // 可识别平台的资产渲染品牌徽章(与 ReleaseCard çš?AssetLeadingIcon 一致)ã€? - // getAllByTitle:simple-icons çš?svg 内部也带 ,需按徽ç«?class 过滤出外å±?spanã€? + // 可识别平台的资产渲染品牌徽章(与 ReleaseCard 的 AssetLeadingIcon 一致)。 + // getAllByTitle:simple-icons 的 svg 内部也带 <title>,需按徽章 class 过滤出外层 span。 const getBadge = (title: string) => screen.getAllByTitle(title).find((el) => el.classList.contains('asset-platform-badge')); expect(getBadge('macOS')).toBeDefined(); expect(getBadge('Windows')).toBeDefined(); expect(getBadge('Linux')).toBeDefined(); - // 平台不可识别的资产回退到通用下载图标,不猜平å�? + // 平台不可识别的资产回退到通用下载图标,不猜平台。 const zipRow = screen.getByText('myapp-1.0.zip').closest('tr'); expect(zipRow).not.toBeNull(); expect(zipRow!.querySelector('.asset-platform-badge')).toBeNull(); diff --git a/src/services/aiRequestLimiter.test.ts b/src/services/aiRequestLimiter.test.ts index c72a7fdb..4d47aa53 100644 --- a/src/services/aiRequestLimiter.test.ts +++ b/src/services/aiRequestLimiter.test.ts @@ -62,7 +62,9 @@ describe('AIRateLimiter', () => { }); it('requestsPerMinute 下并发 acquire 不超发', async () => { - const limiter = new AIRateLimiter({ requestsPerMinute: 2, rpmWindowMs: 500 }); + // 窗口取 2s、探测取 200ms:真实计时器下若 CI 事件循环短暂卡顿, + // 探测窗仍远小于限流窗口,避免过期定时器晚触发导致的偶发超发。 + const limiter = new AIRateLimiter({ requestsPerMinute: 2, rpmWindowMs: 2000 }); let acquired = 0; const pending = Array.from({ length: 4 }, async () => { const release = await limiter.acquire(); diff --git a/src/services/autoSync.repoHash.test.ts b/src/services/autoSync.repoHash.test.ts index a9fe8e57..20d5c91f 100644 --- a/src/services/autoSync.repoHash.test.ts +++ b/src/services/autoSync.repoHash.test.ts @@ -89,8 +89,6 @@ describe('backend sync hash convergence (Issue #304 loop-breaker)', () => { // The fix commits the raw backend hash (firstPullHash), not quickHash(merged). expect(firstPullHash).not.toBe(JSON.stringify(merged)); - // Second poll re-hashes the same backend payload → equals the committed hash. - expect(JSON.stringify(backendPayload)).toBe(firstPullHash); }); it('merged output is stable across repeated merges of the same inputs', () => { @@ -179,8 +177,10 @@ it('omits client-only fields from both pull and successful-push hashes', () => { describe('syncFromBackend two-pull loop (Issue #304 end-to-end)', () => { const backendPayload = [createRepository(1, { ai_summary: 'from backend' })]; + let originalState: ReturnType<typeof useAppStore.getState>; beforeEach(() => { + originalState = useAppStore.getState(); vi.mocked(backend.fetchRepositories).mockResolvedValue({ repositories: backendPayload, total: 1 }); vi.mocked(backend.fetchReleases).mockResolvedValue({ releases: [], total: 0 }); vi.mocked(backend.fetchAIConfigs).mockResolvedValue([]); @@ -195,6 +195,11 @@ describe('syncFromBackend two-pull loop (Issue #304 end-to-end)', () => { readmeMaxChars: 6000, }); vi.mocked(backend.fetchSettings).mockResolvedValue({}); + resetSyncHashes(); + }); + + afterEach(() => { + useAppStore.setState(originalState); }); it('applies a changed backend payload once; an unchanged second pull is a no-op', async () => { diff --git a/src/services/webdavService.test.ts b/src/services/webdavService.test.ts index 21bb98e7..78bde37a 100644 --- a/src/services/webdavService.test.ts +++ b/src/services/webdavService.test.ts @@ -63,18 +63,16 @@ describe('WebDAVService 传输层选择', () => { expect(headers?.Authorization).toBeUndefined(); }); - it('把调用方的 AbortSignal 与 timeoutMs 透传给后端代理', async () => { + it('把内部 AbortSignal 与 timeoutMs 透传给后端代理', async () => { vi.mocked(backend).isAvailable = true; proxyWebDAV.mockResolvedValue(new Response('{}', { status: 200 })); - const controller = new AbortController(); await davService().fileExists('data.json'); - // fileExists 内部自建 controller;这里只断言 timeoutMs 被显式传递 + // fileExists 内部自建 controller;这里断言 signal 与 timeoutMs 被显式传递 const call = proxyWebDAV.mock.calls[0]; expect(call[6]).toBeInstanceOf(AbortSignal); expect(call[7]).toBe(10000); - expect(controller.signal.aborted).toBe(false); }); it('上传时透传按体积计算出的动态超时', async () => { diff --git a/src/store/__fixtures__/categoryLanguage.test.ts b/src/store/__fixtures__/categoryLanguage.test.ts index 3a3db91e..702ae493 100644 --- a/src/store/__fixtures__/categoryLanguage.test.ts +++ b/src/store/__fixtures__/categoryLanguage.test.ts @@ -102,7 +102,6 @@ describe('内置分类语言行为 fixture(zh/en 基线)', () => { const snapshot = buildSnapshot(); if (shouldUpdateFixtures()) { writeFixture(FIXTURE_FILE, snapshot); - expect(snapshot).not.toBeNull(); return; } expect(snapshot).toEqual(readFixture(FIXTURE_FILE)); diff --git a/src/store/persistence/xTweetAuthPersistence.test.ts b/src/store/persistence/xTweetAuthPersistence.test.ts index a4ae0fc4..001eebd0 100644 --- a/src/store/persistence/xTweetAuthPersistence.test.ts +++ b/src/store/persistence/xTweetAuthPersistence.test.ts @@ -58,9 +58,11 @@ describe('xTweetAuth persistence & security', () => { }, }; - if (typeof appPersistenceOptions.migrate === 'function') { - const migrated = appPersistenceOptions.migrate(rawState as unknown, 14) as Record<string, unknown>; - expect('xTweetAuth' in migrated).toBe(false); + // 显式断言前置条件:migrate 缺失时测试必须失败,而不是静默跳过校验 + if (typeof appPersistenceOptions.migrate !== 'function') { + throw new Error('appPersistenceOptions.migrate 必须存在,否则遗留明文 xTweetAuth 快照无法被迁移清理'); } + const migrated = appPersistenceOptions.migrate(rawState as unknown, 14) as Record<string, unknown>; + expect('xTweetAuth' in migrated).toBe(false); }); }); diff --git a/src/utils/abortUtils.test.ts b/src/utils/abortUtils.test.ts index 94e58a2f..8c099560 100644 --- a/src/utils/abortUtils.test.ts +++ b/src/utils/abortUtils.test.ts @@ -47,7 +47,8 @@ describe('createCombinedAbortController', () => { const controller = createCombinedAbortController(undefined, 5_000); controller.abort(); - // 定时器已被清理:继续推进时间不应再触发任何 abort 副作用 + // 定时器已被清理:挂起定时器计数归零,推进时间也不应再触发任何 abort 副作用 + expect(vi.getTimerCount()).toBe(0); vi.advanceTimersByTime(10_000); expect(controller.signal.aborted).toBe(true); });