diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b1559d986b..397ad2eb92 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,7 +21,7 @@ jobs: - name: Code lint checker run: pnpm checker - test-ee: + test: runs-on: ubuntu-latest if: ${{ !contains(github.event.pull_request.title, '[skip checker]') }} strategy: @@ -34,65 +34,41 @@ jobs: - name: Install dependencies uses: ./.github/actions/catch-install-pnpm - - name: Coverage test report ee - run: sh ./scripts/jest/run-ci-ee.sh ${{ matrix.shard }} ${{ strategy.job-total }} + - name: Coverage test report + run: sh ./scripts/jest/run-ci.sh ${{ matrix.shard }} ${{ strategy.job-total }} - uses: actions/upload-artifact@v4 with: name: coverage-artifacts-${{ matrix.shard }} path: coverage/ - test-ce: - runs-on: ubuntu-latest - if: ${{ !contains(github.event.pull_request.title, '[skip checker]') }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install dependencies - uses: ./.github/actions/catch-install-pnpm - - - name: Coverage test report ce - run: sh ./scripts/jest/run-ci-ce.sh - - - uses: actions/upload-artifact@v4 - with: - name: ce-coverage-artifacts - path: ce_coverage/ - report: runs-on: ubuntu-latest if: ${{ !contains(github.event.pull_request.title, '[skip checker]') }} - needs: [test-ee, test-ce] + needs: [test] steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Get CE Coverage - uses: actions/download-artifact@v4 - with: - name: ce-coverage-artifacts - path: ce_coverage - - - name: Get EE Coverage 1 + - name: Get Coverage 1 uses: actions/download-artifact@v4 with: name: coverage-artifacts-1 path: coverage - - name: Get EE Coverage 2 + - name: Get Coverage 2 uses: actions/download-artifact@v4 with: name: coverage-artifacts-2 path: coverage - - name: Get EE Coverage 3 + - name: Get Coverage 3 uses: actions/download-artifact@v4 with: name: coverage-artifacts-3 path: coverage - - name: Get EE Coverage 4 + - name: Get Coverage 4 uses: actions/download-artifact@v4 with: name: coverage-artifacts-4 @@ -115,7 +91,6 @@ jobs: uses: geekyeggo/delete-artifact@v5 with: name: | - ce-coverage-artifacts coverage-artifacts-1 coverage-artifacts-2 coverage-artifacts-3 diff --git a/jest.config.js b/jest.config.js index 631a7d8b79..6515087c59 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,7 +7,37 @@ const { pathsToModuleNameMapper } = require('ts-jest'); compilerOptions.paths['~/*'][0] = path.resolve(compilerOptions.paths['~/*'][0]); -module.exports = { +const sharedModuleNameMapper = { + '.+\\.(css|style|less|sass|scss|ttf|woff|woff2)$': 'identity-obj-proxy', + '@ant-design/plots': + '/packages/shared/lib/testUtil/mockModule/mockAntDesignPlots.jsx', + 'monaco-editor': + '/packages/shared/lib/testUtil/mockModule/mockEditor.jsx', + '@monaco-editor/react': + '/packages/shared/lib/testUtil/mockModule/mockEditor.jsx', + '@uiw/react-md-editor': + '/packages/shared/lib/testUtil/mockModule/mockEditor.jsx', + '@actiontech/(.*)': '/packages/$1', + '@react-sigma/core(.*)$': + '/packages/shared/lib/testUtil/mockModule/mockSigmaCore.tsx', + '@react-sigma/graph-search$': + '/packages/shared/lib/testUtil/mockModule/mockSigmaGraphSearch.tsx', + ...pathsToModuleNameMapper(compilerOptions.paths) +}; + +const sharedIgnorePatterns = ['/node_modules/', '/demo/', '/demos/']; + +// Naming conventions for condition-specific test files: +// *.ce.test.{ts,tsx} → CE project (ee=false, ce=true, sqle=true, dms=false) 不要强制匹配 ce.test, ce.[可选项].test.{ts,tsx} +// *.sqle.test.{ts,tsx} → EE project (ee=true, ce=false, sqle=true, dms=false) 同上 +// *.provision.test.{ts,tsx} → PROVISION project (ee=true, ce=false, sqle=false, provision=true, dms=false) 同上 +// *.test.{ts,tsx} → DMS project (ee=true, ce=false, sqle=true, provision=true, dms=true) [default] 同上 +// 实现:`.ce.` / `.sqle.` / `.provision.` 与 `.test.` 之间可有零段或多段 `.xxx.`(正则见下方 *_TEST_FILE_RE)。 +const CE_TEST_FILE_RE = '\\.ce(\\.[^./]+)*\\.test\\.[jt]sx?$'; +const SQLE_TEST_FILE_RE = '\\.sqle(\\.[^./]+)*\\.test\\.[jt]sx?$'; +const PROVISION_TEST_FILE_RE = '\\.provision(\\.[^./]+)*\\.test\\.[jt]sx?$'; + +const sharedProjectConfig = { transform: { '^.+\\.(ts|tsx|js|jsx)$': '/scripts/jest/custom-transform.js', '^.+\\.(png|jpg|jpeg|css|json)$': '/scripts/jest/file-transform.js' @@ -19,24 +49,7 @@ module.exports = { moduleFileExtensions: ['ts', 'tsx', 'js', 'json', 'jsx', 'node'], testEnvironment: 'jest-environment-jsdom', resetMocks: true, - moduleNameMapper: { - '.+\\.(css|style|less|sass|scss|ttf|woff|woff2)$': 'identity-obj-proxy', - '@ant-design/plots': - '/packages/shared/lib/testUtil/mockModule/mockAntDesignPlots.jsx', - 'monaco-editor': - '/packages/shared/lib/testUtil/mockModule/mockEditor.jsx', - '@monaco-editor/react': - '/packages/shared/lib/testUtil/mockModule/mockEditor.jsx', - '@uiw/react-md-editor': - '/packages/shared/lib/testUtil/mockModule/mockEditor.jsx', - '@actiontech/(.*)': '/packages/$1', - '@react-sigma/core(.*)$': - '/packages/shared/lib/testUtil/mockModule/mockSigmaCore.tsx', - '@react-sigma/graph-search$': - '/packages/shared/lib/testUtil/mockModule/mockSigmaGraphSearch.tsx', - ...pathsToModuleNameMapper(compilerOptions.paths) - }, - + moduleNameMapper: sharedModuleNameMapper, collectCoverageFrom: [ 'packages/**/{src,lib}/{page,components,hooks,global,store,utils}/**/*.{ts,tsx}', 'packages/**/src/App.tsx', @@ -49,8 +62,78 @@ module.exports = { '!packages/**/demo/**', '!packages/**/demos/**' ], - setupFilesAfterEnv: ['/jest-setup.ts'], - testPathIgnorePatterns: ['/node_modules/', '/demo/', '/demos/'], + setupFilesAfterEnv: ['/jest-setup.ts'] +}; + +module.exports = { + projects: [ + { + ...sharedProjectConfig, + displayName: 'dms', + globals: { + TEST_CONDITIONS: { + ee: true, + ce: false, + sqle: true, + provision: true, + dms: true + } + }, + // Default tests only: exclude CE / sqle / provision condition tests (dedicated projects) + testPathIgnorePatterns: [ + ...sharedIgnorePatterns, + CE_TEST_FILE_RE, + SQLE_TEST_FILE_RE, + PROVISION_TEST_FILE_RE + ] + }, + { + ...sharedProjectConfig, + displayName: 'sqle-ce', + globals: { + TEST_CONDITIONS: { + ee: false, + ce: true, + sqle: true, + provision: false, + dms: false + } + }, + testRegex: CE_TEST_FILE_RE, + testPathIgnorePatterns: sharedIgnorePatterns + }, + { + ...sharedProjectConfig, + displayName: 'sqle-ee', + globals: { + TEST_CONDITIONS: { + ee: true, + ce: false, + sqle: true, + provision: false, + dms: false + } + }, + testRegex: SQLE_TEST_FILE_RE, + // e.g. *.ce.sqle.test.* belongs to CE, not EE + testPathIgnorePatterns: [...sharedIgnorePatterns, CE_TEST_FILE_RE] + }, + { + ...sharedProjectConfig, + displayName: 'provision', + globals: { + TEST_CONDITIONS: { + ee: true, + ce: false, + sqle: false, + provision: true, + dms: false + } + }, + testRegex: PROVISION_TEST_FILE_RE, + testPathIgnorePatterns: [...sharedIgnorePatterns, CE_TEST_FILE_RE] + } + ], reporters: [ 'default', [ diff --git a/package.json b/package.json index 793e9cf061..567c18aebd 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "checker": "concurrently \"pnpm ts-check\" \"pnpm eslint\" \"pnpm stylelint\" \"pnpm prettier:c\"", "test": "sh ./scripts/jest/run.sh", "test:c": "sh ./scripts/jest/run-coverage.sh", - "test:ci": "sh ./scripts/jest/run-ci-ee.sh && sh ./scripts/jest/run-ci-ce.sh && node ./scripts/jest/merge-report-json.js", + "test:ci": "sh ./scripts/jest/run-ci.sh 1 1 && node ./scripts/jest/merge-report-json.js", "test:clean": "jest --clearCache", "icon:g": "pnpm --filter @actiontech/icons icon:g", "icon:docs:g": "pnpm --filter @actiontech/icons docs:g", @@ -149,4 +149,4 @@ "@babel/core": "^7.22.0", "@ant-design/cssinjs": "1.17.0" } -} +} \ No newline at end of file diff --git a/packages/base/src/hooks/useOpPermission/index.sqle.test.tsx b/packages/base/src/hooks/useOpPermission/index.sqle.test.tsx new file mode 100644 index 0000000000..89ea116535 --- /dev/null +++ b/packages/base/src/hooks/useOpPermission/index.sqle.test.tsx @@ -0,0 +1,55 @@ +import { cleanup, act, renderHook } from '@testing-library/react'; +import useOpPermission from '.'; +import userCenter from '@actiontech/shared/lib/testUtil/mockApi/base/userCenter'; +import { ListOpPermissionsServiceEnum } from '@actiontech/shared/lib/api/base/service/OpPermission/index.enum'; + +// Verifies the [sqle && !dms] branch: service is set to sqle when calling ListOpPermissions + +describe('test useOpPermission - sqle mode', () => { + let listOpPermissionSpy: jest.SpyInstance; + + beforeEach(() => { + listOpPermissionSpy = userCenter.getOpPermissionsList(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + cleanup(); + }); + + it('should call ListOpPermissions with service=sqle', async () => { + const { result } = renderHook(() => useOpPermission()); + + act(() => { + result.current.updateOpPermissionList(); + }); + + await act(async () => jest.advanceTimersByTime(3000)); + + expect(listOpPermissionSpy).toHaveBeenCalledTimes(1); + expect(listOpPermissionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + service: ListOpPermissionsServiceEnum.sqle + }) + ); + }); + + it('should call ListOpPermissions with service=sqle when filterBy is provided', async () => { + const { result } = renderHook(() => useOpPermission()); + + act(() => { + result.current.updateOpPermissionList(); + }); + + await act(async () => jest.advanceTimersByTime(3000)); + + expect(listOpPermissionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + service: ListOpPermissionsServiceEnum.sqle + }) + ); + expect(result.current.loading).toBeFalsy(); + expect(result.current.opPermissionList.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/base/src/page/DataSource/components/Form/__snapshots__/index.ce.test.tsx.snap b/packages/base/src/page/DataSource/components/Form/__snapshots__/index.ce.test.tsx.snap index dfe5f0a2c1..acf08869e0 100644 --- a/packages/base/src/page/DataSource/components/Form/__snapshots__/index.ce.test.tsx.snap +++ b/packages/base/src/page/DataSource/components/Form/__snapshots__/index.ce.test.tsx.snap @@ -667,7 +667,7 @@ exports[`page/DataSource/DataSourceForm CE render business field when getProject
- @@ -315,12 +314,6 @@ exports[`page/DataSource/DataSourceList render list snap 1`] = ` > 环境属性 - - 数据查询脱敏 - - -
-   -
-
+
+ + + +`; + +exports[`base/Nav/SideMenu/MenuList - sqle mode 权限过滤 hides permission-gated items when checkPagePermission returns false 1`] = ` + +
+ + + +`; + +exports[`base/Nav/SideMenu/MenuList - sqle mode 权限过滤 shows all items when checkPagePermission returns true 1`] = ` + +
+ + + +`; + +exports[`base/Nav/SideMenu/MenuList - sqle mode 权限过滤 shows all items when userOperationPermissions is null (default) - snapshot 1`] = ` + +
+ + + +`; + +exports[`base/Nav/SideMenu/MenuList - sqle mode 路由激活菜单 selects member when at member route 1`] = ` + +
+ + + +`; + +exports[`base/Nav/SideMenu/MenuList - sqle mode 路由激活菜单 selects project-overview when at sqle overview route 1`] = ` + +
+ + + +`; + +exports[`base/Nav/SideMenu/MenuList - sqle mode 路由激活菜单 selects rule-template by walking up nested sub-path 1`] = ` + +
+ + + +`; diff --git a/packages/base/src/page/Nav/SideMenu/MenuList/__snapshots__/index.test.tsx.snap b/packages/base/src/page/Nav/SideMenu/MenuList/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..140f0e1a79 --- /dev/null +++ b/packages/base/src/page/Nav/SideMenu/MenuList/__snapshots__/index.test.tsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`base/page/Nav/SideMenu/MenuList - dms mode renders empty menu in dms mode - snapshot 1`] = ` + +
+
@@ -2318,6 +2332,20 @@ exports[`base/System-ee render snap 2`] = ` Git SSH配置
+
@@ -4582,6 +4610,20 @@ exports[`base/System-ee render snap 3`] = ` Git SSH配置
+
@@ -6277,6 +6319,20 @@ exports[`base/System-ee should changed current active tab when url params is exi Git SSH配置 + diff --git a/packages/base/src/page/System/index.test.tsx b/packages/base/src/page/System/index.test.tsx index c4cf691dfb..48a40e845f 100644 --- a/packages/base/src/page/System/index.test.tsx +++ b/packages/base/src/page/System/index.test.tsx @@ -63,7 +63,7 @@ describe('base/System-ee', () => { '.ant-segmented-item-label', baseElement ); - expect(segmentedEle.length).toBe(7); + expect(segmentedEle.length).toBe(8); fireEvent.click(segmentedEle[1]); await act(async () => jest.advanceTimersByTime(500)); expect(baseElement).toMatchSnapshot(); diff --git a/packages/base/src/page/UserCenter/components/PermissionList/__tests__/PermissionList.sqle.test.tsx b/packages/base/src/page/UserCenter/components/PermissionList/__tests__/PermissionList.sqle.test.tsx new file mode 100644 index 0000000000..3895c7f678 --- /dev/null +++ b/packages/base/src/page/UserCenter/components/PermissionList/__tests__/PermissionList.sqle.test.tsx @@ -0,0 +1,52 @@ +import userCenter from '@actiontech/shared/lib/testUtil/mockApi/base/userCenter'; +import { superRender } from '@actiontech/shared/lib/testUtil/superRender'; +import PermissionList from '../List'; +import { act, cleanup } from '@testing-library/react'; +import { UserCenterListEnum } from '../../../index.enum'; +import { ListOpPermissionsServiceEnum } from '@actiontech/shared/lib/api/base/service/OpPermission/index.enum'; + +// Verifies the [sqle && !dms] branch: service is set to sqle when calling ListOpPermissions + +describe('base/UserCenter/PermissionList - sqle mode', () => { + let permissionListSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + permissionListSpy = userCenter.getOpPermissionsList(); + }); + + afterEach(() => { + jest.useRealTimers(); + cleanup(); + }); + + it('should call ListOpPermissions with service=sqle', async () => { + superRender( + + ); + + await act(async () => jest.advanceTimersByTime(3000)); + + expect(permissionListSpy).toHaveBeenCalledTimes(1); + expect(permissionListSpy).toHaveBeenCalledWith( + expect.objectContaining({ + service: ListOpPermissionsServiceEnum.sqle + }) + ); + }); + + it('should render permission table in sqle mode', async () => { + const { baseElement } = superRender( + + ); + + await act(async () => jest.advanceTimersByTime(3000)); + + expect(permissionListSpy).toHaveBeenCalledWith( + expect.objectContaining({ + service: ListOpPermissionsServiceEnum.sqle + }) + ); + expect(baseElement).toMatchSnapshot(); + }); +}); diff --git a/packages/base/src/page/UserCenter/components/PermissionList/__tests__/__snapshots__/PermissionList.sqle.test.tsx.snap b/packages/base/src/page/UserCenter/components/PermissionList/__tests__/__snapshots__/PermissionList.sqle.test.tsx.snap new file mode 100644 index 0000000000..2d094ef501 --- /dev/null +++ b/packages/base/src/page/UserCenter/components/PermissionList/__tests__/__snapshots__/PermissionList.sqle.test.tsx.snap @@ -0,0 +1,309 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`base/UserCenter/PermissionList - sqle mode should render permission table in sqle mode 1`] = ` + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 权限作用范围 + + 功能模块 + + 权限点 +
+ unknown + + test module + + 创建项目 +
+ 数据源 + + test module + + 创建项目 +
+ 数据源 + + test module + + 修改项目 +
+
+
+
+
    +
  • + + 共 3 条数据 + +
  • +
  • + +
  • +
  • + + 1 + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+ +`; diff --git a/packages/sqle/src/page/ReportStatistics/__snapshots__/ce.test.tsx.snap b/packages/sqle/src/page/ReportStatistics/__snapshots__/index.ce.test.tsx.snap similarity index 100% rename from packages/sqle/src/page/ReportStatistics/__snapshots__/ce.test.tsx.snap rename to packages/sqle/src/page/ReportStatistics/__snapshots__/index.ce.test.tsx.snap diff --git a/packages/sqle/src/page/ReportStatistics/ce.test.tsx b/packages/sqle/src/page/ReportStatistics/index.ce.test.tsx similarity index 100% rename from packages/sqle/src/page/ReportStatistics/ce.test.tsx rename to packages/sqle/src/page/ReportStatistics/index.ce.test.tsx diff --git a/packages/sqle/src/page/Whitelist/__snapshots__/ce.test.tsx.snap b/packages/sqle/src/page/Whitelist/__snapshots__/index.ce.test.tsx.snap similarity index 100% rename from packages/sqle/src/page/Whitelist/__snapshots__/ce.test.tsx.snap rename to packages/sqle/src/page/Whitelist/__snapshots__/index.ce.test.tsx.snap diff --git a/packages/sqle/src/page/Whitelist/ce.test.tsx b/packages/sqle/src/page/Whitelist/index.ce.test.tsx similarity index 100% rename from packages/sqle/src/page/Whitelist/ce.test.tsx rename to packages/sqle/src/page/Whitelist/index.ce.test.tsx diff --git a/scripts/jest/README.md b/scripts/jest/README.md new file mode 100644 index 0000000000..3d0d3bd88e --- /dev/null +++ b/scripts/jest/README.md @@ -0,0 +1,315 @@ +# 单元测试执行方案 + +本文档描述 dms-ui 项目的单元测试架构、执行原理及日常操作手册。 + +--- + +## 背景与问题 + +本项目使用 [`vite-plugin-conditional-compile`](https://github.com/KeJunMao/vite-plugin-conditional-compile) 进行条件编译,允许同一份源文件在不同构建模式下输出不同代码: + +```tsx +// #if [sqle && !dms] +menus = genMenuItemsWithMenuStructTree(SQLE_ALL_MENUS, SQLE_MENU_STRUCT); +// #else +menus = genMenuItemsWithMenuStructTree(DMS_ALL_MENUS, DMS_MENU_STRUCT); +// #endif +``` + +条件变量包括:`ee`、`ce`、`sqle`、`provision`、`dms`、`demo`。 + +**核心挑战**: + +1. **分支代码覆盖**:一份测试只能覆盖一种编译结果,需要多套条件分别运行 +2. **条件组合多样**:条件之间可组合(如 `sqle && !dms`),单纯的 CE/EE 二分已不足够 +3. **缓存污染风险**:在同一 Jest 进程内以不同条件编译同一文件,若缓存 key 相同会导致编译结果错乱 + +--- + +## 解决方案:Jest Projects + +利用 Jest 的 **Projects** 特性,在一次 `jest` 调用中运行多个独立的测试配置。每个 project 拥有独立的: + +- `globals.TEST_CONDITIONS`(编译条件变量) +- `testMatch` / `testPathIgnorePatterns`(测试文件归属规则) +- 独立的 transform 缓存 key(由 `custom-transform.js` 保证) + +### 架构图 + +``` +pnpm jest +│ +├── project: ee (dms=true) → 运行 *.test.tsx / *.ee.test.tsx +├── project: ce (ce=true) → 运行 *.ce.test.tsx / *.ce.sqle.test.tsx +└── project: sqle (dms=false) → 运行 *.sqle.test.tsx + │ + └── 每个 project 由 custom-transform.js 用对应条件编译源代码 +``` + +--- + +## 三个 Project 的配置 + +| Project | displayName | ee | ce | sqle | provision | dms | demo | +|---|---|---|---|---|---|---|---| +| EE(默认)| `ee` | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | +| CE(社区版)| `ce` | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | +| SQLE-only | `sqle` | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | + +### 各 Project 的文件匹配规则 + +**ee project**(默认): + +- 包含:`*.test.tsx`、`*.test.ts`(所有普通测试文件) +- 排除:`*.ce.test.*`、`*.sqle.test.*`(这些由其他 project 处理) + +**ce project**: + +- 仅包含:`*.ce.test.{ts,tsx}`、`*.ce.sqle.test.{ts,tsx}` + +**sqle project**: + +- 仅包含:`*.sqle.test.{ts,tsx}` +- 排除:`*.ce.sqle.test.*`(已归属 ce project) + +--- + +## 自定义 Transformer(`custom-transform.js`) + +这是整个方案的核心。它负责: + +1. **读取当前 project 的 `TEST_CONDITIONS`**(通过 `options.config.globals.TEST_CONDITIONS`) +2. **调用 `vitePlugin({ env: conditions }).transform`** 对源代码进行条件编译预处理 +3. **生成唯一 cache key**(`md5(baseKey + JSON.stringify(conditions))`)防止不同 project 间缓存污染 +4. **交给 `babel-jest`** 完成后续的 JSX/TS 转换 + +``` +源文件 (.tsx) + │ + ▼ +vite-plugin-conditional-compile ← 注入 TEST_CONDITIONS,移除不符合条件的代码块 + │ + ▼ +babel-jest (babel-preset-react-app) ← JSX / TypeScript 转换 + │ + ▼ +Jest 可执行的 JS 代码 +``` + +### 缓存 key 策略 + +```javascript +getCacheKey: (sourceText, sourcePath, options) => { + const conditions = options?.config?.globals?.TEST_CONDITIONS ?? getDefaultConditions(); + const baseKey = babelJestConfig.getCacheKey(sourceText, sourcePath, options); + return crypto.createHash('md5') + .update(baseKey) + .update(JSON.stringify(conditions)) + .digest('hex'); +} +``` + +同一源文件在 `ee` 和 `sqle` project 下会生成**不同的缓存 key**,确保两套编译结果各自独立存储。 + +--- + +## 测试文件命名约定 + +| 文件名 | 归属 Project | 对应源码分支 | +|---|---|---| +| `Foo.test.tsx` | ee | `#else` / 默认 EE/DMS 分支 | +| `Foo.ee.test.tsx` | ee | 同上(显式标注) | +| `Foo.ce.test.tsx` | ce | `#if [ce]` / `#if [!ee]` 分支 | +| `Foo.ce.sqle.test.tsx` | ce | `#if [ce && sqle]` 组合条件分支 | +| `Foo.sqle.test.tsx` | sqle | `#if [sqle && !dms]` 分支 | + +**实际示例**: + +``` +packages/base/src/page/Nav/SideMenu/MenuList/ +├── index.tsx # 源文件(含 #if [sqle && !dms] 分支) +├── index.test.tsx # ee project → dms=true,DMS 模式(空菜单) +└── index.sqle.test.tsx # sqle project → dms=false,SQLE 专属菜单 +``` + +--- + +## 本地运行命令 + +所有命令均通过 `package.json` scripts 调用: + +### 开发时监视运行 + +```bash +# 运行全部 projects(ee + ce + sqle) +pnpm test + +# 按路径过滤(推荐:开发时只跑相关文件) +pnpm test packages/base/src/page/Nav/SideMenu/MenuList + +# 指定 project + 路径(sqle / ce / ee) +pnpm test packages/base/src/page/Nav/SideMenu/MenuList sqle + +# 只跑某个 project 的全部测试 +pnpm test "" sqle +``` + +### 覆盖率报告(本地) + +```bash +# 全量覆盖率 +pnpm test:c + +# 路径过滤 +pnpm test:c packages/base/src/page/Nav/SideMenu/MenuList + +# 指定 project +pnpm test:c packages/base/src/page/Nav/SideMenu/MenuList sqle +``` + +### 更新快照 + +```bash +# 更新指定文件的快照(需取消 CI 环境变量) +CI= pnpm jest --updateSnapshot --testPathPattern="MenuList/index" + +# 更新指定 project 的快照 +CI= pnpm jest --selectProjects sqle --updateSnapshot --testPathPattern="MenuList/index" +``` + +### 清理缓存 + +```bash +pnpm test:clean +``` + +--- + +## CI 流程(GitHub Actions) + +### 执行步骤 + +``` +┌─────────────────────────────────────────────────────────┐ +│ test job (matrix: shard [1,2,3,4]) │ +│ │ +│ run-ci.sh $shard_index $shard_count │ +│ │ │ +│ ├── pnpm test:clean │ +│ ├── pnpm jest --ci --shard=$i/4 │ +│ │ ├── ee project ┐ │ +│ │ ├── ce project ├── 全部 project 的测试 │ +│ │ └── sqle project ┘ 被统一 shard │ +│ ├── coverage/report-$i.json │ +│ └── coverage/coverage-final-$i.json │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ report job (needs: test) │ +│ │ +│ 下载 4 个 shard 的 artifacts │ +│ node merge-report-json.js │ +│ ├── 合并 4 份 report-*.json(测试计数) │ +│ └── 合并 4 份 coverage-final-*.json(覆盖率) │ +│ │ +│ → coverage-merged.json(最终报告) │ +└─────────────────────────────────────────────────────────┘ +``` + +### Shard 机制 + +Jest 的 `--shard=i/N` 将**所有 project 的测试文件总集合**平均分配到 N 个 runner 上。 + +- 添加新的 project 会自动被纳入分片,无需修改 CI 配置 +- `SHARD_COUNT` 环境变量控制 `merge-report-json.js` 的合并逻辑(默认值 4,与 matrix 一致) + +### 关键脚本 + +| 脚本 | 用途 | +|---|---| +| `scripts/jest/run-ci.sh` | CI 单个 shard 的完整执行流程 | +| `scripts/jest/merge-report-json.js` | 合并所有 shard 的覆盖率与测试报告 | +| `scripts/jest/custom-transform.js` | 条件编译 + Babel 的自定义 transformer | +| `scripts/jest/run.sh` | 本地开发监视模式运行入口 | +| `scripts/jest/run-coverage.sh` | 本地覆盖率报告运行入口 | + +--- + +## 如何添加新的 Jest Project + +当出现新的条件组合(例如需要专门覆盖 `provision=true, dms=false` 的代码分支)时: + +### 第一步:在 `jest.config.js` 中添加 project + +```javascript +{ + ...sharedProjectConfig, + displayName: 'provision', + globals: { + TEST_CONDITIONS: { + ee: true, ce: false, sqle: true, + provision: true, dms: false, demo: false + } + }, + testMatch: ['**/*.provision.test.{ts,tsx}'], + testPathIgnorePatterns: sharedIgnorePatterns +} +``` + +### 第二步:按约定命名测试文件 + +``` +Foo.provision.test.tsx +``` + +### 第三步:编写测试 + +测试文件无需任何特殊配置,直接按普通测试文件结构编写即可。 + +### 第四步:验证 + +```bash +pnpm test "" provision +``` + +--- + +## 常见问题 + +### Q:为何在 ee project 下 MenuList 的菜单是空的? + +**A**:`dms=true` 时源码走 `#else` 分支,使用 `DMS_ALL_MENUS` 和 `DMS_MENU_STRUCT`。这两个变量在本仓库中均为空数组(DMS 菜单由 `dms-ui-ee` 仓库维护)。这是预期行为,对应的 `index.test.tsx` 已验证此空菜单状态。 + +### Q:如何确认某个测试文件在哪个 project 下运行? + +**A**:运行时 Jest 会在每行测试结果前显示 project 名,例如: + +``` +PASS ee packages/base/src/page/Nav/SideMenu/MenuList/index.test.tsx +PASS sqle packages/base/src/page/Nav/SideMenu/MenuList/index.sqle.test.tsx +``` + +也可以用 `--selectProjects sqle` 明确指定只运行 sqle project。 + +### Q:更新快照时为何提示 "New snapshot was not written"? + +**A**:这是因为 `CI=true` 环境变量被设置。本地更新快照时需要: + +```bash +CI= pnpm jest --updateSnapshot --testPathPattern="" +``` + +### Q:两个 project 能运行同一个测试文件吗? + +**A**:不能,文件命名约定保证了互斥性。`ee` project 通过 `testPathIgnorePatterns` 排除了 `*.ce.test.*` 和 `*.sqle.test.*`;`ce` 和 `sqle` project 通过 `testMatch` 只匹配特定命名模式。 + +--- + +## 参考文件 + +- [`jest.config.js`](../../jest.config.js) — Jest Projects 完整配置 +- [`scripts/jest/custom-transform.js`](./custom-transform.js) — 条件编译 transformer +- [`scripts/jest/run-ci.sh`](./run-ci.sh) — CI shard 执行脚本 +- [`scripts/jest/merge-report-json.js`](./merge-report-json.js) — 报告合并脚本 +- [`.github/workflows/main.yml`](../../.github/workflows/main.yml) — GitHub Actions CI 配置 +- [`.cursor/commands/unit-testing.md`](../../.cursor/commands/unit-testing.md) — 单元测试编写规范 diff --git a/scripts/jest/custom-filter.js b/scripts/jest/custom-filter.js deleted file mode 100644 index a508c7f602..0000000000 --- a/scripts/jest/custom-filter.js +++ /dev/null @@ -1,43 +0,0 @@ -const fs = require('fs'); -const { parse: parseComments } = require('comment-parser'); - -const testRegex = new RegExp('(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$'); - -const filteringFunction = (path) => { - const code = fs.readFileSync(path).toString(); - - if (testRegex.test(path)) { - const metadata = parseComments(code)[0]?.tags ?? []; - const testVersion = metadata.find((v) => v.tag === 'test_version')?.name; - - if (process.env?.JEST_TEST_VERSION_ENV === 'ee') { - const testProdVersion = metadata.find( - (v) => v.tag === 'test_prod_version' - )?.name; - - if (testProdVersion) { - return ( - process.env.JEST_TEST_PROD_VERSION_ENV === testProdVersion && - (!testVersion || testVersion === 'ee') - ); - } - return !testVersion || testVersion === 'ee'; - } - - if (process.env.JEST_TEST_VERSION_ENV === 'ce') { - return testVersion === 'ce'; - } - } - - return true; -}; - -module.exports = (testPaths) => { - const allowedPaths = testPaths - .filter(filteringFunction) - .map((test) => ({ test })); - - return { - filtered: allowedPaths - }; -}; diff --git a/scripts/jest/custom-transform.js b/scripts/jest/custom-transform.js index 661df78b85..d2ec8b4035 100644 --- a/scripts/jest/custom-transform.js +++ b/scripts/jest/custom-transform.js @@ -1,17 +1,6 @@ /* eslint-disable @typescript-eslint/no-var-requires */ +const crypto = require('crypto'); const vitePlugin = require('vite-plugin-conditional-compile'); - -const testEnv = process.env?.JEST_TEST_VERSION_ENV; - -const { transform } = vitePlugin({ - env: { - sqle: true, - dms: true, - ee: testEnv === 'ee', - ce: testEnv === 'ce' - } -}); - const babelJest = require('babel-jest'); const hasJsxRuntime = (() => { @@ -40,12 +29,56 @@ const babelJestConfig = babelJest.createTransformer({ configFile: false }); +// Cache transform instances per condition set to avoid recreating on each file +const transformCache = new Map(); + +function getTransform(conditions) { + const key = JSON.stringify(conditions); + if (!transformCache.has(key)) { + const { transform } = vitePlugin({ env: conditions }); + transformCache.set(key, transform); + } + return transformCache.get(key); +} + +function getDefaultConditions() { + // Backward compatibility: fall back to process.env when not running via Jest Projects + const testEnv = process.env?.JEST_TEST_VERSION_ENV; + return { + sqle: true, + dms: true, + provision: true, + demo: false, + ee: testEnv !== 'ce', + ce: testEnv === 'ce' + }; +} + const config = { ...babelJestConfig, + + // Include TEST_CONDITIONS in the cache key so CE and EE projects + // never share cached transform results for the same source file. + // Note: in Jest 29 TransformOptions, globals lives at options.config.globals, + // not at options.globals directly. + getCacheKey: (sourceText, sourcePath, options) => { + const conditions = + options?.config?.globals?.TEST_CONDITIONS ?? getDefaultConditions(); + const baseKey = babelJestConfig.getCacheKey(sourceText, sourcePath, options); + return crypto + .createHash('md5') + .update(baseKey) + .update(JSON.stringify(conditions)) + .digest('hex'); + }, + process: (src, filename, options) => { + const conditions = + options?.config?.globals?.TEST_CONDITIONS ?? getDefaultConditions(); + const transform = getTransform(conditions); const code = transform(src, filename); - return babelJestConfig.process(code, filename, options); } }; + module.exports = config; diff --git a/scripts/jest/merge-report-json.js b/scripts/jest/merge-report-json.js index bb033e02aa..ff05e65c9c 100644 --- a/scripts/jest/merge-report-json.js +++ b/scripts/jest/merge-report-json.js @@ -4,71 +4,28 @@ const path = require('path'); const fs = require('fs'); const istanbul = require('istanbul-lib-coverage'); +const SHARD_COUNT = parseInt(process.env.SHARD_COUNT || '4', 10); + const finalReportJSONFilePath = path.resolve( process.cwd(), 'coverage-merged.json' ); -const ceReportJSONFilePath = path.resolve( - process.cwd(), - 'ce_coverage/report.json' -); -const report1JSONFilePath = path.resolve( - process.cwd(), - 'coverage/report-1.json' -); -const report2JSONFilePath = path.resolve( - process.cwd(), - 'coverage/report-2.json' -); -const report3JSONFilePath = path.resolve( - process.cwd(), - 'coverage/report-3.json' -); -const report4JSONFilePath = path.resolve( - process.cwd(), - 'coverage/report-4.json' -); - -if (!fs.existsSync(ceReportJSONFilePath)) { - console.error(`not found ce report.json ${ceReportJSONFilePath}`); - process.exit(1); -} - -if (!fs.existsSync(report1JSONFilePath)) { - console.error(`not found report1.json: ${report1JSONFilePath}`); - process.exit(1); -} - -if (!fs.existsSync(report2JSONFilePath)) { - console.error(`not found report2.json: ${report2JSONFilePath}`); - process.exit(1); -} - -if (!fs.existsSync(report3JSONFilePath)) { - console.error(`not found report3.json: ${report3JSONFilePath}`); - process.exit(1); -} - -if (!fs.existsSync(report4JSONFilePath)) { - console.error(`not found report4.json: ${report4JSONFilePath}`); - process.exit(1); +for (let i = 1; i <= SHARD_COUNT; i++) { + const reportPath = path.resolve( + process.cwd(), + `coverage/report-${i}.json` + ); + if (!fs.existsSync(reportPath)) { + console.error(`not found report-${i}.json: ${reportPath}`); + process.exit(1); + } } -const ceCoverageReport = require(ceReportJSONFilePath); -const coverage1Report = require(report1JSONFilePath); -const coverage2Report = require(report2JSONFilePath); -const coverage3Report = require(report3JSONFilePath); -const coverage4Report = require(report4JSONFilePath); - -const coverageJsonReport = [ - ceCoverageReport, - coverage1Report, - coverage2Report, - coverage3Report, - coverage4Report -].reduce((acc, cur) => { - return { +const coverageJsonReport = Array.from({ length: SHARD_COUNT }, (_, i) => + require(path.resolve(process.cwd(), `coverage/report-${i + 1}.json`)) +).reduce( + (acc, cur) => ({ numFailedTestSuites: (acc.numFailedTestSuites ?? 0) + cur.numFailedTestSuites, numFailedTests: (acc.numFailedTests ?? 0) + cur.numFailedTests, @@ -84,45 +41,32 @@ const coverageJsonReport = [ numTotalTestSuites: (acc.numTotalTestSuites ?? 0) + cur.numTotalTestSuites, numTotalTests: (acc.numTotalTests ?? 0) + cur.numTotalTests, success: (acc.success ?? true) && cur.success - }; -}, {}); + }), + {} +); -if (!fs.existsSync(path.resolve(process.cwd(), 'coverage-merged'))) { - fs.mkdirSync('coverage-merged'); +const coverageMergedDir = path.resolve(process.cwd(), 'coverage-merged'); +if (!fs.existsSync(coverageMergedDir)) { + fs.mkdirSync(coverageMergedDir); } -if ( - fs.existsSync(path.resolve(process.cwd(), 'ce_coverage/coverage-final.json')) -) { - fs.renameSync( - path.resolve(process.cwd(), 'ce_coverage/coverage-final.json'), - path.resolve(process.cwd(), 'coverage-merged/ce-coverage-final.json') +for (let i = 1; i <= SHARD_COUNT; i++) { + const src = path.resolve( + process.cwd(), + `coverage/coverage-final-${i}.json` ); -} - -[1, 2, 3, 4].forEach((num) => { - if ( - fs.existsSync( - path.resolve(process.cwd(), `coverage/coverage-final-${num}.json`) - ) - ) { + if (fs.existsSync(src)) { fs.renameSync( - path.resolve(process.cwd(), `coverage/coverage-final-${num}.json`), - path.resolve(process.cwd(), `coverage-merged/coverage-map-${num}.json`) + src, + path.resolve(coverageMergedDir, `coverage-map-${i}.json`) ); } -}); +} const mergeCoverageReport = istanbul.createCoverageMap({}); -const reportFiles = fs.readdirSync( - path.resolve(process.cwd(), 'coverage-merged') -); - -reportFiles.forEach((file) => { - const json = fs.readFileSync( - path.resolve(process.cwd(), path.join('coverage-merged', file)) - ); +fs.readdirSync(coverageMergedDir).forEach((file) => { + const json = fs.readFileSync(path.resolve(coverageMergedDir, file)); mergeCoverageReport.merge(JSON.parse(json)); }); @@ -137,6 +81,6 @@ fs.writeFile( process.exit(1); } - console.log('Coverage report appended to ' + finalReportJSONFilePath); + console.log('Coverage report merged to ' + finalReportJSONFilePath); } ); diff --git a/scripts/jest/run-ci-ce.sh b/scripts/jest/run-ci-ce.sh deleted file mode 100644 index 5e0b42a69e..0000000000 --- a/scripts/jest/run-ci-ce.sh +++ /dev/null @@ -1,13 +0,0 @@ -export JEST_TEST_VERSION_ENV="ce" -pnpm test:clean -pnpm jest --ci --maxWorkers=50% --silent --watchAll=false --filter='/scripts/jest/custom-filter' --coverage --coverageDirectory=ce_coverage --json --testLocationInResults --outputFile=ce_coverage/report.json -if [ $? -ne 0 ]; then - echo "Jest test ce failed." - exit 1 -fi - -rm -rf ce_coverage/lcov-report ce_coverage/clover.xml ce_coverage/locv.info -if [ $? -ne 0 ]; then - echo "Jest test ce failed." - exit 1 -fi \ No newline at end of file diff --git a/scripts/jest/run-ci-ee.sh b/scripts/jest/run-ci-ee.sh deleted file mode 100644 index 5c98730574..0000000000 --- a/scripts/jest/run-ci-ee.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh - -shard_index=${1:-1} -shard_count=${2:-1} - -export JEST_TEST_VERSION_ENV="ee" -pnpm test:clean -pnpm jest --ci --maxWorkers=50% --silent --watchAll=false --filter='/scripts/jest/custom-filter' --coverage --coverageDirectory=coverage --json --testLocationInResults --outputFile=coverage/report.json --shard=$shard_index/$shard_count -if [ $? -ne 0 ]; then - echo "Jest test failed." - exit 1 -fi - -mv coverage/report.json coverage/report-$shard_index.json -if [ $? -ne 0 ]; then - echo "Jest test failed." - exit 1 -fi - - -mv coverage/coverage-final.json coverage/coverage-final-$shard_index.json -if [ $? -ne 0 ]; then - echo "Jest test failed." - exit 1 -fi - -rm -rf coverage/lcov-report coverage/clover.xml coverage/locv.info -if [ $? -ne 0 ]; then - echo "Jest test failed." - exit 1 -fi \ No newline at end of file diff --git a/scripts/jest/run-ci.sh b/scripts/jest/run-ci.sh new file mode 100644 index 0000000000..0d04ad862f --- /dev/null +++ b/scripts/jest/run-ci.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +shard_index=${1:-1} +shard_count=${2:-1} + +pnpm test:clean +pnpm jest --ci --maxWorkers=50% --silent --watchAll=false --coverage --coverageDirectory=coverage --json --testLocationInResults --outputFile=coverage/report.json --shard=$shard_index/$shard_count +if [ $? -ne 0 ]; then + echo "Jest test failed." + exit 1 +fi + +mv coverage/report.json coverage/report-$shard_index.json +if [ $? -ne 0 ]; then + echo "Move report.json failed." + exit 1 +fi + +mv coverage/coverage-final.json coverage/coverage-final-$shard_index.json +if [ $? -ne 0 ]; then + echo "Move coverage-final.json failed." + exit 1 +fi + +rm -rf coverage/lcov-report coverage/clover.xml coverage/lcov.info +if [ $? -ne 0 ]; then + echo "Jest test failed." + exit 1 +fi diff --git a/scripts/jest/run-coverage.sh b/scripts/jest/run-coverage.sh index 6797c24540..0bb8bb6291 100644 --- a/scripts/jest/run-coverage.sh +++ b/scripts/jest/run-coverage.sh @@ -1,9 +1,26 @@ #!/bin/sh -test_version=${2:-ee} +# Usage: pnpm test:c [path] [project] +# +# Arguments: +# path - (optional) test path pattern, e.g. packages/sqle/src/components/Foo +# project - (optional) project name: ee | ce (default: runs both) -echo "current test version: $test_version" +test_path="${1:-}" +test_project="${2:-}" + +echo "project: ${test_project:-all} | path: ${test_path:-all}" -export JEST_TEST_VERSION_ENV=$test_version pnpm test:clean -pnpm jest --watchAll=false --filter='/scripts/jest/custom-filter' --coverage --coverageDirectory='coverage' --logHeapUsage $1 + +base_args="--watchAll=false --coverage --coverageDirectory=coverage --logHeapUsage" + +if [ -n "$test_project" ]; then + base_args="$base_args --selectProjects $test_project" +fi + +if [ -n "$test_path" ]; then + pnpm jest $base_args --testPathPattern="$test_path" +else + pnpm jest $base_args +fi diff --git a/scripts/jest/run.sh b/scripts/jest/run.sh index 2f28eb6e25..5044549ccc 100644 --- a/scripts/jest/run.sh +++ b/scripts/jest/run.sh @@ -1,8 +1,29 @@ #!/bin/sh -test_version=${2:-ee} -echo "current test version: $test_version" +# Usage: pnpm test [path] [project] +# +# Arguments: +# path - (optional) test path pattern, e.g. packages/sqle/src/components/Foo +# project - (optional) project name: ee | ce (default: runs both) +# +# Examples: +# pnpm test → all projects, all tests +# pnpm test "" ee → ee project, all tests +# pnpm test packages/sqle/src/components/Foo → all projects, filtered path +# pnpm test packages/sqle/src/components/Foo ee → ee project, filtered path -export JEST_TEST_VERSION_ENV=$test_version +test_path="${1:-}" +test_project="${2:-}" -pnpm jest --maxWorkers=50% --watchAll=true --filter='/scripts/jest/custom-filter' --logHeapUsage $1 +base_args="--maxWorkers=50% --watchAll=true" + +if [ -n "$test_project" ]; then + base_args="$base_args --selectProjects $test_project" +fi + +if [ -n "$test_path" ]; then + # Use --testPathPattern explicitly; positional args are ignored in --watchAll mode + pnpm jest $base_args --testPathPattern="$test_path" +else + pnpm jest $base_args +fi