"添加前端模板和运行代码模块"
This commit is contained in:
185
qiming-vite-plugin-design-mode/test/README.md
Normal file
185
qiming-vite-plugin-design-mode/test/README.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 测试文档
|
||||
|
||||
本目录包含 `@xagi/vite-plugin-design-mode` 插件的单元测试。
|
||||
|
||||
## 测试结构
|
||||
|
||||
```
|
||||
test/
|
||||
├── index.test.ts # 主插件入口测试
|
||||
├── core/
|
||||
│ ├── serverMiddleware.test.ts # 服务器中间件测试
|
||||
│ ├── astTransformer.test.ts # AST转换器测试
|
||||
│ ├── codeUpdater.test.ts # 代码更新器测试
|
||||
│ └── sourceMapper.test.ts # 源码映射器测试
|
||||
└── utils/
|
||||
└── babelHelpers.test.ts # Babel辅助函数测试
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
### 运行所有测试
|
||||
|
||||
```bash
|
||||
npm test
|
||||
# 或
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### 运行测试(单次运行,不监听)
|
||||
|
||||
```bash
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
### 运行测试并生成覆盖率报告
|
||||
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
覆盖率报告将生成在 `coverage/` 目录中。
|
||||
|
||||
### 使用UI界面运行测试
|
||||
|
||||
```bash
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
这将打开一个交互式的测试UI界面。
|
||||
|
||||
## 测试覆盖范围
|
||||
|
||||
### 核心功能测试
|
||||
|
||||
1. **主插件入口 (`index.test.ts`)**
|
||||
- 插件初始化
|
||||
- 配置选项处理
|
||||
- Vite hooks 集成
|
||||
- 文件过滤逻辑
|
||||
|
||||
2. **服务器中间件 (`serverMiddleware.test.ts`)**
|
||||
- 健康检查端点
|
||||
- 获取源码端点
|
||||
- 修改源码端点
|
||||
- 错误处理
|
||||
|
||||
3. **AST转换器 (`astTransformer.test.ts`)**
|
||||
- JSX代码转换
|
||||
- 源码映射属性注入
|
||||
- TypeScript支持
|
||||
- 嵌套元素处理
|
||||
|
||||
4. **代码更新器 (`codeUpdater.test.ts`)**
|
||||
- 样式更新
|
||||
- 内容更新
|
||||
- 文件路径处理
|
||||
- 错误处理
|
||||
|
||||
5. **源码映射器 (`sourceMapper.test.ts`)**
|
||||
- Babel插件创建
|
||||
- 元素ID生成
|
||||
- 位置信息注入
|
||||
- 自定义属性前缀
|
||||
|
||||
6. **Babel辅助函数 (`babelHelpers.test.ts`)**
|
||||
- React组件名识别
|
||||
- JSX元素名称提取
|
||||
- 属性值提取
|
||||
- 位置字符串处理
|
||||
|
||||
## 编写新测试
|
||||
|
||||
### 测试文件命名
|
||||
|
||||
- 测试文件应以 `.test.ts` 或 `.spec.ts` 结尾
|
||||
- 测试文件应放在与被测试文件相同的目录结构中
|
||||
|
||||
### 测试结构
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { functionToTest } from '../../src/module';
|
||||
|
||||
describe('模块名称', () => {
|
||||
beforeEach(() => {
|
||||
// 每个测试前的设置
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('功能描述', () => {
|
||||
it('应该执行预期行为', () => {
|
||||
// 测试代码
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Mock使用
|
||||
|
||||
使用 Vitest 的 `vi.mock()` 来模拟依赖:
|
||||
|
||||
```typescript
|
||||
vi.mock('fs', () => {
|
||||
return {
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### 异步测试
|
||||
|
||||
使用 `async/await` 处理异步操作:
|
||||
|
||||
```typescript
|
||||
it('应该处理异步操作', async () => {
|
||||
const result = await asyncFunction();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
## 测试最佳实践
|
||||
|
||||
1. **测试隔离**: 每个测试应该独立运行,不依赖其他测试的状态
|
||||
2. **清晰命名**: 测试描述应该清楚地说明测试的目的
|
||||
3. **覆盖边界情况**: 测试正常流程、错误情况和边界条件
|
||||
4. **Mock外部依赖**: 使用Mock来隔离被测试的代码
|
||||
5. **保持测试简单**: 每个测试应该只测试一个功能点
|
||||
6. **使用描述性的断言**: 使用有意义的错误消息
|
||||
|
||||
## 持续集成
|
||||
|
||||
测试可以在CI/CD流程中自动运行:
|
||||
|
||||
```yaml
|
||||
# GitHub Actions 示例
|
||||
- name: Run tests
|
||||
run: npm run test:run
|
||||
|
||||
- name: Generate coverage
|
||||
run: npm run test:coverage
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 测试失败
|
||||
|
||||
1. 检查测试环境是否正确配置
|
||||
2. 确认所有依赖已安装
|
||||
3. 查看测试输出中的错误信息
|
||||
4. 检查Mock是否正确设置
|
||||
|
||||
### 覆盖率低
|
||||
|
||||
1. 检查是否有未测试的代码路径
|
||||
2. 添加更多边界情况测试
|
||||
3. 确保所有公共API都有测试覆盖
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [Vitest 文档](https://vitest.dev/)
|
||||
- [Testing Library](https://testing-library.com/)
|
||||
- [Jest 迁移指南](https://vitest.dev/guide/migration.html)
|
||||
|
||||
214
qiming-vite-plugin-design-mode/test/core/astTransformer.test.ts
Normal file
214
qiming-vite-plugin-design-mode/test/core/astTransformer.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { transformSourceCode } from '../../packages/plugin/src/core/astTransformer';
|
||||
import type { DesignModeOptions } from '../../packages/plugin/src/types';
|
||||
|
||||
describe('astTransformer', () => {
|
||||
const mockOptions: Required<DesignModeOptions> = {
|
||||
enabled: true,
|
||||
enableInProduction: false,
|
||||
attributePrefix: 'data-source',
|
||||
verbose: false,
|
||||
exclude: ['node_modules'],
|
||||
include: ['**/*.{js,jsx,ts,tsx}'],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('transformSourceCode', () => {
|
||||
it('应该转换简单的JSX代码', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return <div>Hello World</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', mockOptions);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toContain('data-source');
|
||||
expect(result).toContain('div');
|
||||
});
|
||||
|
||||
it('应该为JSX元素添加源码映射属性', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Title</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', mockOptions);
|
||||
|
||||
expect(result).toContain('data-source-element-id');
|
||||
expect(result).toContain('data-source-position');
|
||||
expect(result).toContain('data-source-info');
|
||||
});
|
||||
|
||||
it('应该处理TypeScript代码', () => {
|
||||
const code = `
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
function App(props: Props) {
|
||||
return <div>{props.title}</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', mockOptions);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toContain('data-source');
|
||||
});
|
||||
|
||||
it('应该处理嵌套的JSX元素', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h1>Content</h1>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', mockOptions);
|
||||
|
||||
// 应该为多个元素添加属性
|
||||
const matches = result.match(/data-source-element-id/g);
|
||||
expect(matches?.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('应该处理带属性的JSX元素', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return (
|
||||
<div className="container" id="app">
|
||||
<button onClick={() => {}} disabled>
|
||||
Click me
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', mockOptions);
|
||||
|
||||
expect(result).toContain('data-source');
|
||||
expect(result).toContain('container');
|
||||
});
|
||||
|
||||
it('应该处理函数组件', () => {
|
||||
const code = `
|
||||
const Button = ({ label }: { label: string }) => {
|
||||
return <button>{label}</button>;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return <Button label="Click" />;
|
||||
}
|
||||
`;
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', mockOptions);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toContain('data-source');
|
||||
});
|
||||
|
||||
it('应该处理类组件', () => {
|
||||
const code = `
|
||||
class App extends React.Component {
|
||||
render() {
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', mockOptions);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toContain('data-source');
|
||||
});
|
||||
|
||||
it('应该在转换失败时返回原始代码', () => {
|
||||
const code = 'invalid syntax {{{{';
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', mockOptions);
|
||||
|
||||
// 应该返回原始代码或处理后的代码
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该使用自定义属性前缀', () => {
|
||||
const customOptions: Required<DesignModeOptions> = {
|
||||
...mockOptions,
|
||||
attributePrefix: 'data-appdev',
|
||||
};
|
||||
|
||||
const code = `
|
||||
function App() {
|
||||
return <div>Test</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', customOptions);
|
||||
|
||||
expect(result).toContain('data-appdev');
|
||||
expect(result).not.toContain('data-source');
|
||||
});
|
||||
|
||||
it('应该处理空代码', () => {
|
||||
const code = '';
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', mockOptions);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该处理只有注释的代码', () => {
|
||||
const code = `
|
||||
// This is a comment
|
||||
/* Another comment */
|
||||
`;
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', mockOptions);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该处理复杂的JSX表达式', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
const items = [1, 2, 3];
|
||||
return (
|
||||
<div>
|
||||
{items.map(item => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const result = transformSourceCode(code, 'test.tsx', mockOptions);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toContain('data-source');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
346
qiming-vite-plugin-design-mode/test/core/codeUpdater.test.ts
Normal file
346
qiming-vite-plugin-design-mode/test/core/codeUpdater.test.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { handleUpdate } from '../../packages/plugin/src/core/codeUpdater';
|
||||
import * as fs from 'fs';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
// Mock fs 模块
|
||||
vi.mock('fs', async () => {
|
||||
const actualFs = await vi.importActual<typeof import('fs')>('fs');
|
||||
const mockFs = {
|
||||
...actualFs,
|
||||
statSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
};
|
||||
return {
|
||||
...mockFs,
|
||||
default: mockFs,
|
||||
};
|
||||
});
|
||||
|
||||
describe('codeUpdater', () => {
|
||||
const mockRoot = '/test/project';
|
||||
|
||||
let mockReq: Partial<IncomingMessage>;
|
||||
let mockRes: Partial<ServerResponse>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
size: 1024,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
|
||||
mockReq = {
|
||||
method: 'POST',
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
mockRes = {
|
||||
statusCode: 200,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('handleUpdate', () => {
|
||||
it('应该拒绝非POST请求', async () => {
|
||||
mockReq.method = 'GET';
|
||||
|
||||
await handleUpdate(mockReq as IncomingMessage, mockRes as ServerResponse, mockRoot);
|
||||
|
||||
expect(mockRes.statusCode).toBe(405);
|
||||
expect(mockRes.end).toHaveBeenCalledWith('Method Not Allowed');
|
||||
});
|
||||
|
||||
it('应该处理样式更新请求', async () => {
|
||||
const sourceCode = `
|
||||
function App() {
|
||||
return <div className="old-class">Content</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(sourceCode);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {});
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
filePath: 'src/App.tsx',
|
||||
line: 2,
|
||||
column: 20,
|
||||
newValue: 'new-class',
|
||||
type: 'style',
|
||||
});
|
||||
|
||||
let dataCallback: (chunk: Buffer) => void;
|
||||
let endCallback: () => void;
|
||||
|
||||
mockReq.on = vi.fn((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
dataCallback = callback;
|
||||
} else if (event === 'end') {
|
||||
endCallback = callback;
|
||||
}
|
||||
}) as any;
|
||||
|
||||
const updatePromise = handleUpdate(mockReq as IncomingMessage, mockRes as ServerResponse, mockRoot);
|
||||
|
||||
// 模拟数据流
|
||||
if (dataCallback!) {
|
||||
dataCallback(Buffer.from(requestBody));
|
||||
}
|
||||
if (endCallback!) {
|
||||
endCallback();
|
||||
}
|
||||
|
||||
await updatePromise;
|
||||
|
||||
expect(fs.existsSync).toHaveBeenCalled();
|
||||
expect(fs.readFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理内容更新请求', async () => {
|
||||
const sourceCode = `
|
||||
function App() {
|
||||
return <div>Old Content</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(sourceCode);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {});
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
filePath: 'src/App.tsx',
|
||||
line: 2,
|
||||
column: 20,
|
||||
newValue: 'New Content',
|
||||
type: 'content',
|
||||
});
|
||||
|
||||
let dataCallback: (chunk: Buffer) => void;
|
||||
let endCallback: () => void;
|
||||
|
||||
mockReq.on = vi.fn((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
dataCallback = callback;
|
||||
} else if (event === 'end') {
|
||||
endCallback = callback;
|
||||
}
|
||||
}) as any;
|
||||
|
||||
const updatePromise = handleUpdate(mockReq as IncomingMessage, mockRes as ServerResponse, mockRoot);
|
||||
|
||||
if (dataCallback!) {
|
||||
dataCallback(Buffer.from(requestBody));
|
||||
}
|
||||
if (endCallback!) {
|
||||
endCallback();
|
||||
}
|
||||
|
||||
await updatePromise;
|
||||
|
||||
expect(fs.existsSync).toHaveBeenCalled();
|
||||
expect(fs.readFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理文件不存在的情况', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
filePath: 'src/NonExistent.tsx',
|
||||
line: 1,
|
||||
column: 0,
|
||||
newValue: 'test',
|
||||
type: 'style',
|
||||
});
|
||||
|
||||
let dataCallback: (chunk: Buffer) => void;
|
||||
let endCallback: () => void;
|
||||
|
||||
mockReq.on = vi.fn((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
dataCallback = callback;
|
||||
} else if (event === 'end') {
|
||||
endCallback = callback;
|
||||
}
|
||||
}) as any;
|
||||
|
||||
const updatePromise = handleUpdate(mockReq as IncomingMessage, mockRes as ServerResponse, mockRoot);
|
||||
|
||||
if (dataCallback!) {
|
||||
dataCallback(Buffer.from(requestBody));
|
||||
}
|
||||
if (endCallback!) {
|
||||
endCallback();
|
||||
}
|
||||
|
||||
await updatePromise;
|
||||
|
||||
expect(mockRes.statusCode).toBe(400);
|
||||
const response = JSON.parse(mockRes.end?.mock.calls[0]?.[0] || '{}');
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain('File not found');
|
||||
});
|
||||
|
||||
it('应该处理绝对路径', async () => {
|
||||
const absolutePath = '/absolute/path/to/file.tsx';
|
||||
const sourceCode = 'function App() { return <div>Test</div>; }';
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(sourceCode);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {});
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
filePath: absolutePath,
|
||||
line: 1,
|
||||
column: 0,
|
||||
newValue: 'test',
|
||||
type: 'style',
|
||||
});
|
||||
|
||||
let dataCallback: (chunk: Buffer) => void;
|
||||
let endCallback: () => void;
|
||||
|
||||
mockReq.on = vi.fn((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
dataCallback = callback;
|
||||
} else if (event === 'end') {
|
||||
endCallback = callback;
|
||||
}
|
||||
}) as any;
|
||||
|
||||
const updatePromise = handleUpdate(mockReq as IncomingMessage, mockRes as ServerResponse, mockRoot);
|
||||
|
||||
if (dataCallback!) {
|
||||
dataCallback(Buffer.from(requestBody));
|
||||
}
|
||||
if (endCallback!) {
|
||||
endCallback();
|
||||
}
|
||||
|
||||
await updatePromise;
|
||||
|
||||
expect(mockRes.statusCode).toBe(400);
|
||||
const response = JSON.parse(mockRes.end?.mock.calls[0]?.[0] || '{}');
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain('Access denied');
|
||||
});
|
||||
|
||||
it('应该处理相对路径', async () => {
|
||||
const relativePath = 'src/App.tsx';
|
||||
const sourceCode = 'function App() { return <div>Test</div>; }';
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(sourceCode);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {});
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
filePath: relativePath,
|
||||
line: 1,
|
||||
column: 0,
|
||||
newValue: 'test',
|
||||
type: 'style',
|
||||
});
|
||||
|
||||
let dataCallback: (chunk: Buffer) => void;
|
||||
let endCallback: () => void;
|
||||
|
||||
mockReq.on = vi.fn((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
dataCallback = callback;
|
||||
} else if (event === 'end') {
|
||||
endCallback = callback;
|
||||
}
|
||||
}) as any;
|
||||
|
||||
const updatePromise = handleUpdate(mockReq as IncomingMessage, mockRes as ServerResponse, mockRoot);
|
||||
|
||||
if (dataCallback!) {
|
||||
dataCallback(Buffer.from(requestBody));
|
||||
}
|
||||
if (endCallback!) {
|
||||
endCallback();
|
||||
}
|
||||
|
||||
await updatePromise;
|
||||
|
||||
// 应该解析为绝对路径
|
||||
expect(fs.existsSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理无效的JSON请求体', async () => {
|
||||
let dataCallback: (chunk: Buffer) => void;
|
||||
let endCallback: () => void;
|
||||
|
||||
mockReq.on = vi.fn((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
dataCallback = callback;
|
||||
} else if (event === 'end') {
|
||||
endCallback = callback;
|
||||
}
|
||||
}) as any;
|
||||
|
||||
const updatePromise = handleUpdate(mockReq as IncomingMessage, mockRes as ServerResponse, mockRoot);
|
||||
|
||||
if (dataCallback!) {
|
||||
dataCallback(Buffer.from('invalid json'));
|
||||
}
|
||||
if (endCallback!) {
|
||||
endCallback();
|
||||
}
|
||||
|
||||
await updatePromise;
|
||||
|
||||
expect(mockRes.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
it('应该处理找不到匹配元素的情况', async () => {
|
||||
const sourceCode = `
|
||||
function App() {
|
||||
return <div>Content</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(sourceCode);
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
filePath: 'src/App.tsx',
|
||||
line: 999, // 不存在的行
|
||||
column: 0,
|
||||
newValue: 'test',
|
||||
type: 'style',
|
||||
});
|
||||
|
||||
let dataCallback: (chunk: Buffer) => void;
|
||||
let endCallback: () => void;
|
||||
|
||||
mockReq.on = vi.fn((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
dataCallback = callback;
|
||||
} else if (event === 'end') {
|
||||
endCallback = callback;
|
||||
}
|
||||
}) as any;
|
||||
|
||||
const updatePromise = handleUpdate(mockReq as IncomingMessage, mockRes as ServerResponse, mockRoot);
|
||||
|
||||
if (dataCallback!) {
|
||||
dataCallback(Buffer.from(requestBody));
|
||||
}
|
||||
if (endCallback!) {
|
||||
endCallback();
|
||||
}
|
||||
|
||||
await updatePromise;
|
||||
|
||||
// 应该返回400错误,因为找不到匹配的元素
|
||||
const response = JSON.parse(mockRes.end?.mock.calls[0]?.[0] || '{}');
|
||||
expect(response.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createServerMiddleware } from '../../packages/plugin/src/core/serverMiddleware';
|
||||
import type { DesignModeOptions } from '../../packages/plugin/src/types';
|
||||
|
||||
// Mock fs 模块
|
||||
vi.mock('fs', async () => {
|
||||
const actualFs = await vi.importActual<typeof import('fs')>('fs');
|
||||
const mockFs = {
|
||||
...actualFs,
|
||||
promises: {
|
||||
...actualFs.promises,
|
||||
access: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
},
|
||||
};
|
||||
return {
|
||||
...mockFs,
|
||||
default: mockFs,
|
||||
};
|
||||
});
|
||||
|
||||
describe('serverMiddleware', () => {
|
||||
const mockOptions: Required<DesignModeOptions> = {
|
||||
enabled: true,
|
||||
enableInProduction: false,
|
||||
attributePrefix: 'data-source',
|
||||
verbose: false,
|
||||
exclude: ['node_modules'],
|
||||
include: ['**/*.{js,jsx,ts,tsx}'],
|
||||
};
|
||||
|
||||
const mockRootDir = '/test/project';
|
||||
|
||||
let mockReq: any;
|
||||
let mockRes: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// 重置 mock
|
||||
vi.clearAllMocks();
|
||||
|
||||
// 创建模拟的请求对象
|
||||
mockReq = {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
// 创建模拟的响应对象
|
||||
mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
end: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('createServerMiddleware', () => {
|
||||
it('应该创建中间件函数', () => {
|
||||
const middleware = createServerMiddleware(mockOptions, mockRootDir);
|
||||
expect(typeof middleware).toBe('function');
|
||||
});
|
||||
|
||||
it('应该处理健康检查请求', async () => {
|
||||
mockReq.url = '/health';
|
||||
|
||||
const middleware = createServerMiddleware(mockOptions, mockRootDir);
|
||||
await middleware(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.statusCode).toBe(200);
|
||||
expect(mockRes.end).toHaveBeenCalled();
|
||||
|
||||
const responseData = JSON.parse(mockRes.end.mock.calls[0][0]);
|
||||
expect(responseData.status).toBe('ok');
|
||||
expect(responseData.plugin).toBe('@xagi/vite-plugin-design-mode');
|
||||
expect(responseData.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该处理获取源码请求 - 成功', async () => {
|
||||
const elementId = 'src/App.tsx:4:5_div_test';
|
||||
mockReq.url = `/get-source?elementId=${encodeURIComponent(elementId)}`;
|
||||
|
||||
const mockFileContent = 'import React from "react";\n\nfunction App() {\n return <div>Test</div>;\n}';
|
||||
vi.mocked(fs.promises.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.stat).mockResolvedValue({ mtime: new Date() } as any);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(mockFileContent);
|
||||
|
||||
const middleware = createServerMiddleware(mockOptions, mockRootDir);
|
||||
await middleware(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.statusCode).toBe(200);
|
||||
expect(fs.promises.readFile).toHaveBeenCalled();
|
||||
|
||||
const responseData = JSON.parse(mockRes.end.mock.calls[0][0]);
|
||||
expect(responseData.sourceInfo).toBeDefined();
|
||||
expect(responseData.sourceInfo.fileName).toBe('src/App.tsx');
|
||||
expect(responseData.sourceInfo.lineNumber).toBe(4);
|
||||
expect(responseData.sourceInfo.columnNumber).toBe(5);
|
||||
});
|
||||
|
||||
it('应该处理获取源码请求 - 缺少elementId参数', async () => {
|
||||
mockReq.url = '/get-source';
|
||||
|
||||
const middleware = createServerMiddleware(mockOptions, mockRootDir);
|
||||
await middleware(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.statusCode).toBe(400);
|
||||
const responseData = JSON.parse(mockRes.end.mock.calls[0][0]);
|
||||
expect(responseData.error).toContain('Missing elementId');
|
||||
});
|
||||
|
||||
it('应该处理获取源码请求 - 无效的elementId格式', async () => {
|
||||
mockReq.url = '/get-source?elementId=invalid';
|
||||
|
||||
const middleware = createServerMiddleware(mockOptions, mockRootDir);
|
||||
await middleware(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.statusCode).toBe(400);
|
||||
const responseData = JSON.parse(mockRes.end.mock.calls[0][0]);
|
||||
expect(responseData.error).toContain('Invalid elementId');
|
||||
});
|
||||
|
||||
it('应该处理修改源码请求 - 成功', async () => {
|
||||
const elementId = 'src/App.tsx:4:5_div_test';
|
||||
mockReq.url = '/modify-source';
|
||||
mockReq.method = 'POST';
|
||||
|
||||
const mockFileContent = 'import React from "react";\n\nfunction App() {\n return <div className="old">Test</div>;\n}';
|
||||
const mockUpdatedContent = 'import React from "react";\n\nfunction App() {\n return <div className="new">Test</div>;\n}';
|
||||
|
||||
vi.mocked(fs.promises.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(mockFileContent);
|
||||
vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
// 模拟请求体
|
||||
const requestBody = JSON.stringify({
|
||||
elementId,
|
||||
newStyles: 'new',
|
||||
oldStyles: 'old',
|
||||
});
|
||||
|
||||
let dataCallback: (chunk: any) => void;
|
||||
let endCallback: () => void;
|
||||
|
||||
mockReq.on = vi.fn((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
dataCallback = callback;
|
||||
} else if (event === 'end') {
|
||||
endCallback = callback;
|
||||
}
|
||||
});
|
||||
|
||||
const middleware = createServerMiddleware(mockOptions, mockRootDir);
|
||||
const middlewarePromise = middleware(mockReq, mockRes);
|
||||
|
||||
// 模拟数据流
|
||||
if (dataCallback!) {
|
||||
dataCallback(Buffer.from(requestBody));
|
||||
}
|
||||
if (endCallback!) {
|
||||
endCallback();
|
||||
}
|
||||
|
||||
await middlewarePromise;
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(mockRes.statusCode).toBe(200);
|
||||
const responseData = JSON.parse(mockRes.end.mock.calls[0][0]);
|
||||
expect(responseData.success).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理修改源码请求 - 方法不允许', async () => {
|
||||
mockReq.url = '/modify-source';
|
||||
mockReq.method = 'GET';
|
||||
|
||||
const middleware = createServerMiddleware(mockOptions, mockRootDir);
|
||||
await middleware(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.statusCode).toBe(405);
|
||||
const responseData = JSON.parse(mockRes.end.mock.calls[0][0]);
|
||||
expect(responseData.error).toContain('Method not allowed');
|
||||
});
|
||||
|
||||
it('应该处理修改源码请求 - 缺少必需参数', async () => {
|
||||
mockReq.url = '/modify-source';
|
||||
mockReq.method = 'POST';
|
||||
|
||||
const requestBody = JSON.stringify({});
|
||||
|
||||
let dataCallback: (chunk: any) => void;
|
||||
let endCallback: () => void;
|
||||
|
||||
mockReq.on = vi.fn((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
dataCallback = callback;
|
||||
} else if (event === 'end') {
|
||||
endCallback = callback;
|
||||
}
|
||||
});
|
||||
|
||||
const middleware = createServerMiddleware(mockOptions, mockRootDir);
|
||||
const middlewarePromise = middleware(mockReq, mockRes);
|
||||
|
||||
if (dataCallback!) {
|
||||
dataCallback(Buffer.from(requestBody));
|
||||
}
|
||||
if (endCallback!) {
|
||||
endCallback();
|
||||
}
|
||||
|
||||
await middlewarePromise;
|
||||
|
||||
expect(mockRes.statusCode).toBe(400);
|
||||
const responseData = JSON.parse(mockRes.end.mock.calls[0][0]);
|
||||
expect(responseData.error).toContain('Missing required parameters');
|
||||
});
|
||||
|
||||
it('应该处理404请求', async () => {
|
||||
mockReq.url = '/unknown';
|
||||
|
||||
const middleware = createServerMiddleware(mockOptions, mockRootDir);
|
||||
await middleware(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.statusCode).toBe(404);
|
||||
const responseData = JSON.parse(mockRes.end.mock.calls[0][0]);
|
||||
expect(responseData.error).toBe('Not found');
|
||||
});
|
||||
|
||||
it('应该处理文件读取错误', async () => {
|
||||
const elementId = 'src/App.tsx:4:5_div_test';
|
||||
mockReq.url = `/get-source?elementId=${encodeURIComponent(elementId)}`;
|
||||
|
||||
vi.mocked(fs.promises.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const middleware = createServerMiddleware(mockOptions, mockRootDir);
|
||||
await middleware(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.statusCode).toBe(500);
|
||||
const responseData = JSON.parse(mockRes.end.mock.calls[0][0]);
|
||||
expect(responseData.error).toBe('File not found');
|
||||
});
|
||||
|
||||
it('应该处理文件写入错误', async () => {
|
||||
const elementId = 'src/App.tsx:4:5_div_test';
|
||||
mockReq.url = '/modify-source';
|
||||
mockReq.method = 'POST';
|
||||
|
||||
const mockFileContent = 'import React from "react";\n\nfunction App() {\n return <div>Test</div>;\n}';
|
||||
vi.mocked(fs.promises.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(mockFileContent);
|
||||
vi.mocked(fs.promises.writeFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
elementId,
|
||||
newStyles: 'new',
|
||||
});
|
||||
|
||||
let dataCallback: (chunk: any) => void;
|
||||
let endCallback: () => void;
|
||||
|
||||
mockReq.on = vi.fn((event: string, callback: any) => {
|
||||
if (event === 'data') {
|
||||
dataCallback = callback;
|
||||
} else if (event === 'end') {
|
||||
endCallback = callback;
|
||||
}
|
||||
});
|
||||
|
||||
const middleware = createServerMiddleware(mockOptions, mockRootDir);
|
||||
const middlewarePromise = middleware(mockReq, mockRes);
|
||||
|
||||
if (dataCallback!) {
|
||||
dataCallback(Buffer.from(requestBody));
|
||||
}
|
||||
if (endCallback!) {
|
||||
endCallback();
|
||||
}
|
||||
|
||||
await middlewarePromise;
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(mockRes.statusCode).toBe(500);
|
||||
const responseData = JSON.parse(mockRes.end.mock.calls[0][0]);
|
||||
expect(responseData.error).toBe('Permission denied');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
262
qiming-vite-plugin-design-mode/test/core/sourceMapper.test.ts
Normal file
262
qiming-vite-plugin-design-mode/test/core/sourceMapper.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createSourceMappingPlugin } from '../../packages/plugin/src/core/sourceMapper';
|
||||
import type { DesignModeOptions } from '../../packages/plugin/src/types';
|
||||
import * as babel from '@babel/standalone';
|
||||
|
||||
describe('sourceMapper', () => {
|
||||
const mockOptions: Required<DesignModeOptions> = {
|
||||
enabled: true,
|
||||
enableInProduction: false,
|
||||
attributePrefix: 'data-source',
|
||||
verbose: false,
|
||||
exclude: ['node_modules'],
|
||||
include: ['**/*.{js,jsx,ts,tsx}'],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createSourceMappingPlugin', () => {
|
||||
it('应该创建Babel插件', () => {
|
||||
const plugin = createSourceMappingPlugin('test.tsx', mockOptions);
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin.visitor).toBeDefined();
|
||||
expect(plugin.visitor.JSXOpeningElement).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该为JSX元素添加源码映射属性', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
const plugin = createSourceMappingPlugin('test.tsx', mockOptions);
|
||||
|
||||
const result = babel.transform(code, {
|
||||
plugins: [plugin],
|
||||
presets: ['react'],
|
||||
});
|
||||
|
||||
expect(result?.code).toBeDefined();
|
||||
expect(result?.code).toContain('data-source');
|
||||
});
|
||||
|
||||
it('应该生成正确的elementId', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return <div className="test">Hello</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
const plugin = createSourceMappingPlugin('src/App.tsx', mockOptions);
|
||||
|
||||
const result = babel.transform(code, {
|
||||
plugins: [plugin],
|
||||
presets: ['react'],
|
||||
});
|
||||
|
||||
expect(result?.code).toBeDefined();
|
||||
expect(result?.code).toContain('data-source-element-id');
|
||||
});
|
||||
|
||||
it('应该添加位置信息属性', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
const plugin = createSourceMappingPlugin('test.tsx', mockOptions);
|
||||
|
||||
const result = babel.transform(code, {
|
||||
plugins: [plugin],
|
||||
presets: ['react'],
|
||||
});
|
||||
|
||||
expect(result?.code).toBeDefined();
|
||||
expect(result?.code).toContain('data-source-position');
|
||||
});
|
||||
|
||||
it('应该添加完整的源码信息属性', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
const plugin = createSourceMappingPlugin('test.tsx', mockOptions);
|
||||
|
||||
const result = babel.transform(code, {
|
||||
plugins: [plugin],
|
||||
presets: ['react'],
|
||||
});
|
||||
|
||||
expect(result?.code).toBeDefined();
|
||||
expect(result?.code).toContain('data-source-info');
|
||||
});
|
||||
|
||||
it('应该使用自定义属性前缀', () => {
|
||||
const customOptions: Required<DesignModeOptions> = {
|
||||
...mockOptions,
|
||||
attributePrefix: 'data-appdev',
|
||||
};
|
||||
|
||||
const code = `
|
||||
function App() {
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
const plugin = createSourceMappingPlugin('test.tsx', customOptions);
|
||||
|
||||
const result = babel.transform(code, {
|
||||
plugins: [plugin],
|
||||
presets: ['react'],
|
||||
});
|
||||
|
||||
expect(result?.code).toBeDefined();
|
||||
expect(result?.code).toContain('data-appdev');
|
||||
expect(result?.code).not.toContain('data-source');
|
||||
});
|
||||
|
||||
it('应该处理嵌套的JSX元素', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<header>
|
||||
<nav>Nav</nav>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const plugin = createSourceMappingPlugin('test.tsx', mockOptions);
|
||||
|
||||
const result = babel.transform(code, {
|
||||
plugins: [plugin],
|
||||
presets: ['react'],
|
||||
});
|
||||
|
||||
expect(result?.code).toBeDefined();
|
||||
// 应该为多个元素添加属性
|
||||
const matches = result?.code.match(/data-source-element-id/g);
|
||||
expect(matches?.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('应该处理带id属性的元素', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return <div id="app">Hello</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
const plugin = createSourceMappingPlugin('test.tsx', mockOptions);
|
||||
|
||||
const result = babel.transform(code, {
|
||||
plugins: [plugin],
|
||||
presets: ['react'],
|
||||
});
|
||||
|
||||
expect(result?.code).toBeDefined();
|
||||
expect(result?.code).toContain('data-source-element-id');
|
||||
});
|
||||
|
||||
it('应该处理带className的元素', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return <div className="container">Hello</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
const plugin = createSourceMappingPlugin('test.tsx', mockOptions);
|
||||
|
||||
const result = babel.transform(code, {
|
||||
plugins: [plugin],
|
||||
presets: ['react'],
|
||||
});
|
||||
|
||||
expect(result?.code).toBeDefined();
|
||||
expect(result?.code).toContain('data-source-element-id');
|
||||
});
|
||||
|
||||
it('应该处理函数组件', () => {
|
||||
const code = `
|
||||
const Button = () => {
|
||||
return <button>Click</button>;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return <Button />;
|
||||
}
|
||||
`;
|
||||
|
||||
const plugin = createSourceMappingPlugin('test.tsx', mockOptions);
|
||||
|
||||
const result = babel.transform(code, {
|
||||
plugins: [plugin],
|
||||
presets: ['react'],
|
||||
});
|
||||
|
||||
expect(result?.code).toBeDefined();
|
||||
expect(result?.code).toContain('data-source');
|
||||
});
|
||||
|
||||
it('应该处理没有位置信息的元素', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
`;
|
||||
|
||||
const plugin = createSourceMappingPlugin('test.tsx', mockOptions);
|
||||
|
||||
// 即使没有位置信息,插件也应该正常工作
|
||||
const result = babel.transform(code, {
|
||||
plugins: [plugin],
|
||||
presets: ['react'],
|
||||
});
|
||||
|
||||
expect(result?.code).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该处理复杂的JSX结构', () => {
|
||||
const code = `
|
||||
function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<h1>Title</h1>
|
||||
</header>
|
||||
<main className="main">
|
||||
<section>
|
||||
<p>Content</p>
|
||||
</section>
|
||||
</main>
|
||||
<footer className="footer">
|
||||
<p>Footer</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const plugin = createSourceMappingPlugin('test.tsx', mockOptions);
|
||||
|
||||
const result = babel.transform(code, {
|
||||
plugins: [plugin],
|
||||
presets: ['react'],
|
||||
});
|
||||
|
||||
expect(result?.code).toBeDefined();
|
||||
// 应该为多个元素添加属性
|
||||
const matches = result?.code.match(/data-source-element-id/g);
|
||||
expect(matches?.length).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { transformVueSfcTemplate } from '../../packages/plugin/src/core/vueSfcTransformer';
|
||||
import type { DesignModeOptions } from '../../packages/plugin/src/types';
|
||||
|
||||
const options: Required<DesignModeOptions> = {
|
||||
enabled: true,
|
||||
enableInProduction: false,
|
||||
attributePrefix: 'data-source',
|
||||
verbose: false,
|
||||
exclude: ['node_modules'],
|
||||
include: ['src/**/*.{js,jsx,ts,tsx,vue}'],
|
||||
enableBackup: false,
|
||||
enableHistory: false,
|
||||
framework: 'auto',
|
||||
};
|
||||
|
||||
function decodeHtmlAttr(value: string): string {
|
||||
return value
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
describe('vueSfcTransformer', () => {
|
||||
it('injects normalized source info fields with legacy compatibility aliases', () => {
|
||||
const source = `<template>
|
||||
<div class="wrap">
|
||||
<h1>Hello Vue</h1>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const message = 'ok';
|
||||
</script>
|
||||
`;
|
||||
|
||||
const transformed = transformVueSfcTemplate(source, 'src/pages/Home.vue', options);
|
||||
const attrMatch = transformed.match(/data-source-info="([^"]+)"/);
|
||||
|
||||
expect(attrMatch).toBeTruthy();
|
||||
const sourceInfo = JSON.parse(decodeHtmlAttr(attrMatch![1])) as Record<string, unknown>;
|
||||
|
||||
expect(sourceInfo.fileName).toBe('src/pages/Home.vue');
|
||||
expect(sourceInfo.lineNumber).toBeTypeOf('number');
|
||||
expect(sourceInfo.columnNumber).toBeTypeOf('number');
|
||||
expect(sourceInfo.line).toBe(sourceInfo.lineNumber);
|
||||
expect(sourceInfo.column).toBe(sourceInfo.columnNumber);
|
||||
});
|
||||
|
||||
it('removes stale mapping attrs before reinjection', () => {
|
||||
const source = `<template>
|
||||
<div data-source-info="{"fileName":"old.vue"}" data-source-position="1:1" data-source-element-id="old">
|
||||
Hello
|
||||
</div>
|
||||
</template>`;
|
||||
|
||||
const transformed = transformVueSfcTemplate(source, 'src/pages/Home.vue', options);
|
||||
const infoAttrs = transformed.match(/data-source-info=/g) ?? [];
|
||||
|
||||
expect(infoAttrs.length).toBe(1);
|
||||
expect(transformed).not.toContain('old.vue');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyVueSfcTemplateUpdate } from '../../packages/plugin/src/core/vueSfcUpdater';
|
||||
|
||||
function getLineColumnByToken(source: string, token: string): { line: number; column: number } {
|
||||
const index = source.indexOf(token);
|
||||
if (index < 0) {
|
||||
throw new Error(`Token not found: ${token}`);
|
||||
}
|
||||
const prefix = source.slice(0, index);
|
||||
const lines = prefix.split('\n');
|
||||
return {
|
||||
line: lines.length,
|
||||
column: (lines[lines.length - 1] ?? '').length + 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('vueSfcUpdater', () => {
|
||||
it('updates static class in Vue template', () => {
|
||||
const source = `<template>
|
||||
<section>
|
||||
<h1 class="title">Hello Vue</h1>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const noop = true;
|
||||
</script>
|
||||
`;
|
||||
const { line, column } = getLineColumnByToken(source, '<h1');
|
||||
const updated = applyVueSfcTemplateUpdate(source, {
|
||||
lineNumber: line,
|
||||
columnNumber: column,
|
||||
type: 'style',
|
||||
newValue: 'title text-2xl',
|
||||
});
|
||||
|
||||
expect(updated).toContain('<h1 class="title text-2xl">Hello Vue</h1>');
|
||||
});
|
||||
|
||||
it('updates static text in Vue template', () => {
|
||||
const source = `<template>
|
||||
<section>
|
||||
<p>Hello Vue</p>
|
||||
</section>
|
||||
</template>
|
||||
<script setup>
|
||||
const noop = true;
|
||||
</script>
|
||||
`;
|
||||
const { line, column } = getLineColumnByToken(source, '<p');
|
||||
const updated = applyVueSfcTemplateUpdate(source, {
|
||||
lineNumber: line,
|
||||
columnNumber: column,
|
||||
type: 'content',
|
||||
originalValue: 'Hello Vue',
|
||||
newValue: 'Hello Agent',
|
||||
});
|
||||
|
||||
expect(updated).toContain('<p>Hello Agent</p>');
|
||||
});
|
||||
});
|
||||
284
qiming-vite-plugin-design-mode/test/index.test.ts
Normal file
284
qiming-vite-plugin-design-mode/test/index.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import appdevDesignModePlugin from '../packages/plugin/src/index';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
describe('@xagi/vite-plugin-design-mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('插件初始化', () => {
|
||||
it('应该创建插件实例', () => {
|
||||
const plugin = appdevDesignModePlugin();
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin.name).toBe('@xagi/vite-plugin-design-mode');
|
||||
});
|
||||
|
||||
it('应该使用默认选项', () => {
|
||||
const plugin = appdevDesignModePlugin();
|
||||
expect(plugin).toBeDefined();
|
||||
|
||||
// 插件应该具有预期的方法
|
||||
expect(typeof plugin.config).toBe('function');
|
||||
expect(typeof plugin.configureServer).toBe('function');
|
||||
expect(typeof plugin.transform).toBe('function');
|
||||
expect(typeof plugin.transformIndexHtml).toBe('function');
|
||||
expect(typeof plugin.buildStart).toBe('function');
|
||||
expect(typeof plugin.buildEnd).toBe('function');
|
||||
});
|
||||
|
||||
it('应该接受自定义选项', () => {
|
||||
const plugin = appdevDesignModePlugin({
|
||||
enabled: false,
|
||||
verbose: true,
|
||||
attributePrefix: 'data-test',
|
||||
exclude: ['custom-exclude'],
|
||||
include: ['custom-include'],
|
||||
});
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin.name).toBe('@xagi/vite-plugin-design-mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config hook', () => {
|
||||
it('应该在开发模式下启用', () => {
|
||||
const plugin = appdevDesignModePlugin({ enabled: true });
|
||||
|
||||
const result = plugin.config?.({}, { command: 'serve' });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.define).toBeDefined();
|
||||
expect(result?.define?.__APPDEV_DESIGN_MODE__).toBe(true);
|
||||
});
|
||||
|
||||
it('应该在构建模式下禁用(如果未启用生产模式)', () => {
|
||||
const plugin = appdevDesignModePlugin({
|
||||
enabled: true,
|
||||
enableInProduction: false,
|
||||
});
|
||||
|
||||
const result = plugin.config?.({}, { command: 'build' });
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('应该在构建模式下启用(如果启用生产模式)', () => {
|
||||
const plugin = appdevDesignModePlugin({
|
||||
enabled: true,
|
||||
enableInProduction: true,
|
||||
});
|
||||
|
||||
const result = plugin.config?.({}, { command: 'build' });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.define?.__APPDEV_DESIGN_MODE__).toBe(true);
|
||||
});
|
||||
|
||||
it('应该在禁用时返回空配置', () => {
|
||||
const plugin = appdevDesignModePlugin({ enabled: false });
|
||||
|
||||
const result = plugin.config?.({}, { command: 'serve' });
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('应该设置verbose标志', () => {
|
||||
const plugin = appdevDesignModePlugin({ verbose: true });
|
||||
|
||||
const result = plugin.config?.({}, { command: 'serve' });
|
||||
|
||||
expect(result?.define?.__APPDEV_DESIGN_MODE_VERBOSE__).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transform hook', () => {
|
||||
it('应该在禁用时返回原始代码', async () => {
|
||||
const plugin = appdevDesignModePlugin({ enabled: false });
|
||||
|
||||
const code = 'function App() { return <div>Test</div>; }';
|
||||
const result = await plugin.transform?.(code, 'test.tsx', {});
|
||||
|
||||
expect(result).toBe(code);
|
||||
});
|
||||
|
||||
it('应该处理匹配的文件', async () => {
|
||||
const plugin = appdevDesignModePlugin({ enabled: true });
|
||||
|
||||
const code = 'function App() { return <div>Test</div>; }';
|
||||
const result = await plugin.transform?.(code, 'src/App.tsx', {});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// 应该被转换(添加了源码映射属性)
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
expect(result.code).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('应该排除node_modules中的文件', async () => {
|
||||
const plugin = appdevDesignModePlugin({ enabled: true });
|
||||
|
||||
const code = 'function App() { return <div>Test</div>; }';
|
||||
const result = await plugin.transform?.(code, 'node_modules/test.tsx', {});
|
||||
|
||||
expect(result).toBe(code);
|
||||
});
|
||||
|
||||
it('应该处理转换错误', async () => {
|
||||
const plugin = appdevDesignModePlugin({
|
||||
enabled: true,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
// 无效的代码应该返回原始代码
|
||||
const code = 'invalid syntax {{{{';
|
||||
const result = await plugin.transform?.(code, 'test.tsx', {});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldProcessFile', () => {
|
||||
it('应该处理匹配include模式的文件', async () => {
|
||||
const plugin = appdevDesignModePlugin({
|
||||
enabled: true,
|
||||
include: ['**/*.tsx'],
|
||||
});
|
||||
|
||||
const code = 'function App() { return <div>Test</div>; }';
|
||||
const result = await plugin.transform?.(code, 'src/App.tsx', {});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该排除匹配exclude模式的文件', async () => {
|
||||
const plugin = appdevDesignModePlugin({
|
||||
enabled: true,
|
||||
exclude: ['test'],
|
||||
});
|
||||
|
||||
const code = 'function App() { return <div>Test</div>; }';
|
||||
const result = await plugin.transform?.(code, 'test/App.tsx', {});
|
||||
|
||||
expect(result).toBe(code);
|
||||
});
|
||||
|
||||
it('应该处理glob模式', async () => {
|
||||
const plugin = appdevDesignModePlugin({
|
||||
enabled: true,
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
});
|
||||
|
||||
const code = 'function App() { return <div>Test</div>; }';
|
||||
const result1 = await plugin.transform?.(code, 'src/App.tsx', {});
|
||||
const result2 = await plugin.transform?.(code, 'src/components/Button.tsx', {});
|
||||
|
||||
expect(result1).toBeDefined();
|
||||
expect(result2).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureServer hook', () => {
|
||||
it('应该在开发模式下配置服务器', () => {
|
||||
const plugin = appdevDesignModePlugin({ enabled: true });
|
||||
|
||||
const mockServer = {
|
||||
config: {
|
||||
command: 'serve' as const,
|
||||
root: '/test',
|
||||
},
|
||||
middlewares: {
|
||||
use: vi.fn(),
|
||||
},
|
||||
} as any;
|
||||
|
||||
plugin.configureServer?.(mockServer);
|
||||
|
||||
expect(mockServer.middlewares.use).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该在禁用时不配置服务器', () => {
|
||||
const plugin = appdevDesignModePlugin({ enabled: false });
|
||||
|
||||
const mockServer = {
|
||||
config: {
|
||||
command: 'serve' as const,
|
||||
root: '/test',
|
||||
},
|
||||
middlewares: {
|
||||
use: vi.fn(),
|
||||
},
|
||||
} as any;
|
||||
|
||||
plugin.configureServer?.(mockServer);
|
||||
|
||||
// 不应该调用use(或者调用但立即返回)
|
||||
// 这里我们只检查不会抛出错误
|
||||
expect(mockServer).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformIndexHtml hook', () => {
|
||||
it('应该在启用时注入客户端脚本', () => {
|
||||
const plugin = appdevDesignModePlugin({ enabled: true });
|
||||
|
||||
const html = '<html><head></head><body></body></html>';
|
||||
const result = plugin.transformIndexHtml?.(html, {
|
||||
path: '/',
|
||||
filename: 'index.html',
|
||||
});
|
||||
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
expect(result.tags).toBeDefined();
|
||||
expect(result.tags?.length).toBeGreaterThan(0);
|
||||
expect(result.tags?.[0]?.tag).toBe('script');
|
||||
}
|
||||
});
|
||||
|
||||
it('应该在禁用时不注入脚本', () => {
|
||||
const plugin = appdevDesignModePlugin({ enabled: false });
|
||||
|
||||
const html = '<html><head></head><body></body></html>';
|
||||
const result = plugin.transformIndexHtml?.(html, {
|
||||
path: '/',
|
||||
filename: 'index.html',
|
||||
});
|
||||
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStart 和 buildEnd hooks', () => {
|
||||
it('应该在verbose模式下输出日志', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
const plugin = appdevDesignModePlugin({ verbose: true });
|
||||
|
||||
plugin.buildStart?.();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[appdev-design-mode] Plugin started'
|
||||
);
|
||||
|
||||
plugin.buildEnd?.();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[appdev-design-mode] Plugin ended'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('应该在非verbose模式下不输出日志', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
const plugin = appdevDesignModePlugin({ verbose: false });
|
||||
|
||||
plugin.buildStart?.();
|
||||
plugin.buildEnd?.();
|
||||
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
211
qiming-vite-plugin-design-mode/test/utils/babelHelpers.test.ts
Normal file
211
qiming-vite-plugin-design-mode/test/utils/babelHelpers.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isReactComponentName,
|
||||
getJSXElementBaseName,
|
||||
isStringLiteralAttribute,
|
||||
extractStringAttributeValue,
|
||||
createSourcePositionString,
|
||||
parseSourcePositionString,
|
||||
} from '../../packages/plugin/src/utils/babelHelpers';
|
||||
import * as t from '@babel/types';
|
||||
|
||||
describe('babelHelpers', () => {
|
||||
describe('isReactComponentName', () => {
|
||||
it('应该识别大写开头的组件名', () => {
|
||||
expect(isReactComponentName('App')).toBe(true);
|
||||
expect(isReactComponentName('Button')).toBe(true);
|
||||
expect(isReactComponentName('MyComponent')).toBe(true);
|
||||
});
|
||||
|
||||
it('应该识别小写开头的非组件名', () => {
|
||||
expect(isReactComponentName('app')).toBe(false);
|
||||
expect(isReactComponentName('button')).toBe(false);
|
||||
expect(isReactComponentName('myComponent')).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理特殊字符', () => {
|
||||
expect(isReactComponentName('_App')).toBe(false);
|
||||
expect(isReactComponentName('$Component')).toBe(false);
|
||||
expect(isReactComponentName('123Component')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJSXElementBaseName', () => {
|
||||
it('应该从JSXIdentifier获取名称', () => {
|
||||
const identifier = t.jSXIdentifier('div');
|
||||
const name = getJSXElementBaseName(identifier);
|
||||
expect(name).toBe('div');
|
||||
});
|
||||
|
||||
it('应该从JSXMemberExpression获取属性名', () => {
|
||||
const memberExpr = t.jSXMemberExpression(
|
||||
t.jSXIdentifier('React'),
|
||||
t.jSXIdentifier('Component')
|
||||
);
|
||||
const name = getJSXElementBaseName(memberExpr);
|
||||
expect(name).toBe('Component');
|
||||
});
|
||||
|
||||
it('应该处理未知类型', () => {
|
||||
const unknown = t.stringLiteral('test');
|
||||
const name = getJSXElementBaseName(unknown as any);
|
||||
expect(name).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStringLiteralAttribute', () => {
|
||||
it('应该识别字符串字面量属性', () => {
|
||||
const attr = t.jSXAttribute(
|
||||
t.jSXIdentifier('className'),
|
||||
t.stringLiteral('test')
|
||||
);
|
||||
|
||||
const result = isStringLiteralAttribute(attr, 'className');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该识别非字符串字面量属性', () => {
|
||||
const attr = t.jSXAttribute(
|
||||
t.jSXIdentifier('className'),
|
||||
t.jSXExpressionContainer(t.identifier('classNameVar'))
|
||||
);
|
||||
|
||||
const result = isStringLiteralAttribute(attr, 'className');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该识别属性名不匹配的情况', () => {
|
||||
const attr = t.jSXAttribute(
|
||||
t.jSXIdentifier('id'),
|
||||
t.stringLiteral('test')
|
||||
);
|
||||
|
||||
const result = isStringLiteralAttribute(attr, 'className');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractStringAttributeValue', () => {
|
||||
it('应该提取字符串属性值', () => {
|
||||
const openingElement = t.jSXOpeningElement(
|
||||
t.jSXIdentifier('div'),
|
||||
[
|
||||
t.jSXAttribute(
|
||||
t.jSXIdentifier('className'),
|
||||
t.stringLiteral('container')
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
const value = extractStringAttributeValue(openingElement, 'className');
|
||||
expect(value).toBe('container');
|
||||
});
|
||||
|
||||
it('应该返回null当属性不存在时', () => {
|
||||
const openingElement = t.jSXOpeningElement(
|
||||
t.jSXIdentifier('div'),
|
||||
[]
|
||||
);
|
||||
|
||||
const value = extractStringAttributeValue(openingElement, 'className');
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
it('应该返回null当属性不是字符串字面量时', () => {
|
||||
const openingElement = t.jSXOpeningElement(
|
||||
t.jSXIdentifier('div'),
|
||||
[
|
||||
t.jSXAttribute(
|
||||
t.jSXIdentifier('className'),
|
||||
t.jSXExpressionContainer(t.identifier('classNameVar'))
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
const value = extractStringAttributeValue(openingElement, 'className');
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
it('应该提取id属性值', () => {
|
||||
const openingElement = t.jSXOpeningElement(
|
||||
t.jSXIdentifier('div'),
|
||||
[
|
||||
t.jSXAttribute(
|
||||
t.jSXIdentifier('id'),
|
||||
t.stringLiteral('app')
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
const value = extractStringAttributeValue(openingElement, 'id');
|
||||
expect(value).toBe('app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSourcePositionString', () => {
|
||||
it('应该创建正确的位置字符串', () => {
|
||||
const position = createSourcePositionString('src/App.tsx', 10, 5);
|
||||
expect(position).toBe('src/App.tsx:10:5');
|
||||
});
|
||||
|
||||
it('应该处理不同的文件名', () => {
|
||||
const position = createSourcePositionString('components/Button.tsx', 20, 15);
|
||||
expect(position).toBe('components/Button.tsx:20:15');
|
||||
});
|
||||
|
||||
it('应该处理行号和列号为0的情况', () => {
|
||||
const position = createSourcePositionString('test.tsx', 0, 0);
|
||||
expect(position).toBe('test.tsx:0:0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSourcePositionString', () => {
|
||||
it('应该解析正确的位置字符串', () => {
|
||||
const result = parseSourcePositionString('src/App.tsx:10:5');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.fileName).toBe('src/App.tsx');
|
||||
expect(result?.lineNumber).toBe(10);
|
||||
expect(result?.columnNumber).toBe(5);
|
||||
});
|
||||
|
||||
it('应该处理不同的文件名', () => {
|
||||
const result = parseSourcePositionString('components/Button.tsx:20:15');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.fileName).toBe('components/Button.tsx');
|
||||
expect(result?.lineNumber).toBe(20);
|
||||
expect(result?.columnNumber).toBe(15);
|
||||
});
|
||||
|
||||
it('应该返回null当格式不正确时', () => {
|
||||
expect(parseSourcePositionString('invalid')).toBeNull();
|
||||
expect(parseSourcePositionString('src/App.tsx:10')).toBeNull();
|
||||
expect(parseSourcePositionString('src/App.tsx:10:5:extra')).toBeNull();
|
||||
});
|
||||
|
||||
it('应该返回null当行号或列号不是数字时', () => {
|
||||
expect(parseSourcePositionString('src/App.tsx:abc:5')).toBeNull();
|
||||
expect(parseSourcePositionString('src/App.tsx:10:def')).toBeNull();
|
||||
expect(parseSourcePositionString('src/App.tsx:abc:def')).toBeNull();
|
||||
});
|
||||
|
||||
it('应该处理行号和列号为0的情况', () => {
|
||||
const result = parseSourcePositionString('test.tsx:0:0');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.lineNumber).toBe(0);
|
||||
expect(result?.columnNumber).toBe(0);
|
||||
});
|
||||
|
||||
it('应该处理负数行号或列号', () => {
|
||||
// 虽然负数在实际情况中不应该出现,但函数应该能处理
|
||||
const result = parseSourcePositionString('test.tsx:-1:-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.lineNumber).toBe(-1);
|
||||
expect(result?.columnNumber).toBe(-1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user