test(ai): 改用行为断言验证代码块依赖边界 (#785)

## 问题背景

`upstream/dev` 的前端全量测试被 `src/testPolicy.test.ts`
阻断。`AIMessageCodeBlock.dependencyBoundary.test.ts` 通过 `readFileSync` 读取
`.ts/.tsx` 源码并用正则断言实现文本,违反项目“禁止新增源码文本断言”的测试策略。

该文件没有进入债务基线,且不属于经认可的全仓库不变式扫描,因此不能通过增加豁免隐藏问题。

## 修复方案

只重写依赖边界测试,不修改生产代码、Vite 配置、测试策略或债务基线:

- 通过模块 mock 的实际求值行为,验证 AI 代码块加载轻量 `prism-light` 入口而不是完整语言注册表。
- 先渲染普通 SQL 代码块,再切换到 Mermaid 代码块,验证 Mermaid 仅在对应组件挂载后加载、初始化并渲染。
- 直接导入 Vite 配置对象,验证 Prism 入口、语言和主题依赖都包含在 `optimizeDeps.include` 中。

这使测试执行实际模块加载与 React effect,不再把源码当字符串锁定。

## 验证结果

| 验证项 | 命令 | 结果 |
| --- | --- | --- |
| 修复前基线 | `npm --prefix frontend test -- src/testPolicy.test.ts` |
按预期失败,唯一 offender 为本次修改文件 |
| 聚焦测试 | `npm --prefix frontend test --
src/components/ai/messageBubble/AIMessageCodeBlock.dependencyBoundary.test.ts
src/components/ai/messageBubble/AIMessageMarkdown.test.tsx
src/testPolicy.test.ts` | 通过,3 个文件、11/11 |
| 前端全量测试 | `npm --prefix frontend test` | 通过,421 个文件、3481/3481 |
| 类型与生产构建 | `npm --prefix frontend run build` | 通过,`tsc && vite build`
成功 |
| 差异检查 | `git diff --cached --check` | 通过 |

## 风险与兼容性

- 只修改测试文件,不改变运行时代码、公共 API、配置、数据格式或依赖。
- 模块 mock 能验证加载入口与加载时机,但不等同于完整的 Rollup 分块分析;最终生产构建已实际执行并通过。
- Vite 预打包依赖清单通过配置对象断言,新增或删除 AI 高亮语言时需要同步更新测试期望。

## 回滚方式

回滚提交 `caba7b8cbb9bed38c27e1fde4c668936957a3cd0`
即可恢复原测试。回滚不影响运行时数据或配置,但会重新触发当前的测试策略失败。
This commit is contained in:
Syngnat
2026-07-29 22:26:35 +08:00
committed by GitHub

View File

@@ -1,27 +1,133 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';
const source = readFileSync(new URL('./AIMessageCodeBlock.tsx', import.meta.url), 'utf8');
const viteConfigSource = readFileSync(new URL('../../../../vite.config.ts', import.meta.url), 'utf8');
import viteConfig from '../../../../vite.config';
import { buildOverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme';
import { AIMessageCodeBlock } from './AIMessageCodeBlock';
const dependencyMocks = vi.hoisted(() => ({
completeRegistryLoad: vi.fn(),
mermaidInitialize: vi.fn(),
mermaidLoad: vi.fn(),
mermaidRender: vi.fn(async () => ({ svg: '<svg>diagram</svg>' })),
prismLightLoad: vi.fn(),
registerLanguage: vi.fn(),
}));
vi.mock('react-syntax-highlighter', () => {
dependencyMocks.completeRegistryLoad();
return { default: () => null };
});
vi.mock('react-syntax-highlighter/dist/esm/prism-light', () => {
dependencyMocks.prismLightLoad();
return {
default: Object.assign(() => null, {
registerLanguage: dependencyMocks.registerLanguage,
}),
};
});
vi.mock('mermaid', () => {
dependencyMocks.mermaidLoad();
return {
default: {
initialize: dependencyMocks.mermaidInitialize,
render: dependencyMocks.mermaidRender,
},
};
});
vi.mock('antd', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => children,
message: { error: vi.fn() },
}));
vi.mock('@ant-design/icons', () => ({
CheckOutlined: () => null,
CopyOutlined: () => null,
PlayCircleOutlined: () => null,
}));
vi.mock('../../common/ResizableDraggableModal', () => ({
default: Object.assign(() => null, { confirm: vi.fn() }),
}));
const codeHighlightDependencies = [
'react-syntax-highlighter/dist/esm/prism-light',
'react-syntax-highlighter/dist/esm/languages/prism/bash',
'react-syntax-highlighter/dist/esm/languages/prism/css',
'react-syntax-highlighter/dist/esm/languages/prism/diff',
'react-syntax-highlighter/dist/esm/languages/prism/go',
'react-syntax-highlighter/dist/esm/languages/prism/ini',
'react-syntax-highlighter/dist/esm/languages/prism/java',
'react-syntax-highlighter/dist/esm/languages/prism/javascript',
'react-syntax-highlighter/dist/esm/languages/prism/json',
'react-syntax-highlighter/dist/esm/languages/prism/jsx',
'react-syntax-highlighter/dist/esm/languages/prism/markdown',
'react-syntax-highlighter/dist/esm/languages/prism/markup',
'react-syntax-highlighter/dist/esm/languages/prism/php',
'react-syntax-highlighter/dist/esm/languages/prism/python',
'react-syntax-highlighter/dist/esm/languages/prism/ruby',
'react-syntax-highlighter/dist/esm/languages/prism/rust',
'react-syntax-highlighter/dist/esm/languages/prism/sql',
'react-syntax-highlighter/dist/esm/languages/prism/toml',
'react-syntax-highlighter/dist/esm/languages/prism/tsx',
'react-syntax-highlighter/dist/esm/languages/prism/typescript',
'react-syntax-highlighter/dist/esm/languages/prism/yaml',
'react-syntax-highlighter/dist/esm/styles/prism/vsc-dark-plus',
'react-syntax-highlighter/dist/esm/styles/prism/vs',
];
const renderCodeBlock = (className: string, children: string) => React.createElement(AIMessageCodeBlock, {
className,
children,
darkMode: false,
overlayTheme: buildOverlayWorkbenchTheme(false),
});
describe('AIMessageCodeBlock dependency boundary', () => {
it('does not pull the complete syntax-highlighter language registry into the AI panel chunk', () => {
expect(source).not.toMatch(/from\s+['"]react-syntax-highlighter['"]/);
expect(source).toMatch(/react-syntax-highlighter\/dist\/esm\/prism-light/);
it('loads the lightweight syntax highlighter without the complete language registry', () => {
expect(dependencyMocks.completeRegistryLoad).not.toHaveBeenCalled();
expect(dependencyMocks.prismLightLoad).toHaveBeenCalledOnce();
expect(dependencyMocks.registerLanguage).toHaveBeenCalledTimes(20);
});
it('loads Mermaid only when a Mermaid fenced block is rendered', () => {
expect(source).not.toMatch(/^import\s+mermaid\s+from\s+['"]mermaid['"];?$/m);
expect(source).toMatch(/await\s+import\(['"]mermaid['"]\)/);
});
it('loads Mermaid only when a Mermaid fenced block is rendered', async () => {
let renderer: ReactTestRenderer | undefined;
let mermaidContainer: { innerHTML: string } | undefined;
it('pre-bundles every static syntax-highlighter entry before Wails opens the panel', () => {
const syntaxHighlighterImports = Array.from(source.matchAll(
/from\s+['"](react-syntax-highlighter\/dist\/esm\/[^'"]+)['"]/g,
)).map((match) => match[1]);
try {
act(() => {
renderer = create(renderCodeBlock('language-sql', 'SELECT 1;'), {
createNodeMock: (element) => {
if (element.type === 'div' && element.props.className === 'ai-mermaid-container') {
mermaidContainer = { innerHTML: '' };
return mermaidContainer;
}
return {};
},
});
});
expect(dependencyMocks.mermaidLoad).not.toHaveBeenCalled();
for (const dependency of syntaxHighlighterImports) {
expect(viteConfigSource).toContain(`'${dependency}'`);
await act(async () => {
renderer?.update(renderCodeBlock('language-mermaid', 'graph TD; A-->B;'));
});
await vi.waitFor(() => {
expect(dependencyMocks.mermaidLoad).toHaveBeenCalledOnce();
expect(dependencyMocks.mermaidInitialize).toHaveBeenCalledWith({ startOnLoad: false, theme: 'default' });
expect(dependencyMocks.mermaidRender).toHaveBeenCalledWith(expect.stringMatching(/^mermaid-/), 'graph TD; A-->B;');
expect(mermaidContainer?.innerHTML).toBe('<svg>diagram</svg>');
});
} finally {
act(() => renderer?.unmount());
}
});
it('pre-bundles every static syntax-highlighter dependency', () => {
const includedDependencies = viteConfig.optimizeDeps?.include || [];
expect(includedDependencies).toEqual(expect.arrayContaining(codeHighlightDependencies));
});
});