chore: merge master into feature

This commit is contained in:
lijianan 2023-12-22 03:26:11 +08:00
commit a09ed506d3
No known key found for this signature in database
GPG Key ID: 7595169217DDC1B5
32 changed files with 689 additions and 138 deletions

View File

@ -87,12 +87,16 @@ jobs:
# Execute visual regression diff task and zip then
# output as visualRegressionReport.tar.gz
- name: visual regression diff
env:
EVENT_NUMBER: ${{ github.event.number }}
BASE_REF: ${{ github.base_ref }}
run: |
npm run visual-regression -- --pr-id=${{ github.event.number }} --base-ref=${{ github.base_ref}}
npm run visual-regression -- --pr-id=$EVENT_NUMBER --base-ref=$BASE_REF
# Upload report in `visualRegressionReport`
- name: upload report artifact
uses: actions/upload-artifact@v3
if: ${{ always() }}
with:
name: visual-regression-report
path: visualRegressionReport.tar.gz

View File

@ -17,9 +17,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
jobs: ${{ steps.visual_diff_build_job_status.outputs.result }}
build-success: ${{ steps.visual_diff_build_job_status.outputs.build-success }}
build-failure: ${{ steps.visual_diff_build_job_status.outputs.build-failure }}
job-status: ${{ steps.visual_diff_build_job_status.outputs.job-status }}
build-status: ${{ steps.visual_diff_build_job_status.outputs.build-status }}
steps:
- name: summary jobs status
uses: actions/github-script@v6
@ -47,9 +45,7 @@ jobs:
console.log('visual-diff report job status: %s', jobs['visual-diff report']);
// set output
core.setOutput('build-success', jobs['visual-diff report'] === 'success');
core.setOutput('build-failure', jobs['visual-diff report'] === 'failure');
core.setOutput('job-status', jobs['visual-diff report']);
core.setOutput('build-status', jobs['visual-diff report']);
return jobs;
download-visual-regression-report:
@ -79,7 +75,8 @@ jobs:
# Download report artifact
- name: download report artifact
if: ${{ fromJSON(needs.upstream-workflow-summary.outputs.build-success) }}
id: download_report
if: ${{ needs.upstream-workflow-summary.outputs.build-status == 'success' || needs.upstream-workflow-summary.outputs.build-status == 'failure' }}
uses: dawidd6/action-download-artifact@v2
with:
workflow: ${{ github.event.workflow_run.workflow_id }}
@ -92,6 +89,7 @@ jobs:
env:
ALI_OSS_AK_ID: ${{ secrets.ALI_OSS_AK_ID }}
ALI_OSS_AK_SECRET: ${{ secrets.ALI_OSS_AK_SECRET }}
PR_ID: ${{ steps.pr.outputs.id }}
run: |
mkdir ./visualRegressionReport
tar -xzvf visualRegressionReport.tar.gz -C ./visualRegressionReport
@ -102,7 +100,7 @@ jobs:
echo "✅ Install `ali-oss` Finished"
echo "🤖 Uploading"
node scripts/visual-regression/upload.js ./visualRegressionReport --ref=pr-${{ steps.pr.outputs.id }}
node scripts/visual-regression/upload.js ./visualRegressionReport --ref=pr-$PR_ID
echo "✅ Uploaded"
delimiter="$(openssl rand -hex 8)"
@ -122,13 +120,16 @@ jobs:
number: ${{ steps.pr.outputs.id }}
- name: failed comment
if: ${{ fromJSON(needs.upstream-workflow-summary.outputs.build-failure) || steps.report.outcome == 'failure' || failure() }}
if: ${{ steps.download_report.outcome == 'failure' || steps.report.outcome == 'failure' || failure() }}
uses: actions-cool/maintain-one-comment@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
body: |
## Visual-Regression Diff Failed
Visual-diff report job conclusion: ${{ fromJSON(needs.upstream-workflow-summary.outputs.job-status) }}
## Visual Regression Build for PR #${{ steps.pr.outputs.id }} Failed ❌
Potential causes:
- `upstream workflow` status: ${{ needs.upstream-workflow-summary.outputs.build-status }}
- `download report artifact` status: ${{ steps.download_report.outcome }}
- `report upload` status: ${{ steps.report.outcome }}
<!-- VISUAL_DIFF_REGRESSION_HOOK -->
body-include: '<!-- VISUAL_DIFF_REGRESSION_HOOK -->'
number: ${{ steps.pr.outputs.id }}

View File

@ -87,18 +87,18 @@ jobs:
- name: Persist Image Snapshot to OSS
if: github.event.workflow_run.event == 'push' && (github.event.workflow_run.head_branch == 'master' || github.event.workflow_run.head_branch == 'feature')
id: persist
continue-on-error: true
env:
ALI_OSS_AK_ID: ${{ secrets.ALI_OSS_AK_ID }}
ALI_OSS_AK_SECRET: ${{ secrets.ALI_OSS_AK_SECRET }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
rm package.json
npm i ali-oss --no-save
echo "✅ Install `ali-oss` Finished"
echo "🤖 Uploading"
node scripts/visual-regression/upload.js ./imageSnapshots.tar.gz --ref=${{ github.event.workflow_run.head_sha }}
node scripts/visual-regression/upload.js ./visual-regression-ref.txt --ref=${{ github.event.workflow_run.head_branch }}
node scripts/visual-regression/upload.js ./imageSnapshots.tar.gz --ref=$HEAD_SHA
node scripts/visual-regression/upload.js ./visual-regression-ref.txt --ref=$HEAD_BRANCH
echo "✅ Uploaded"

View File

@ -2,7 +2,7 @@ const { moduleNameMapper, transformIgnorePatterns } = require('./.jest');
// jest config for image snapshots
module.exports = {
setupFiles: ['./tests/setup.js'],
setupFiles: ['./tests/setup.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'md'],
moduleNameMapper,
transform: {
@ -19,5 +19,5 @@ module.exports = {
},
},
preset: 'jest-puppeteer',
testTimeout: 10000,
testTimeout: 20000,
};

View File

@ -32,7 +32,7 @@ function getTestRegex(libDir) {
module.exports = {
verbose: true,
testEnvironment: 'jsdom',
setupFiles: ['./tests/setup.js', 'jest-canvas-mock'],
setupFiles: ['./tests/setup.ts', 'jest-canvas-mock'],
setupFilesAfterEnv: ['./tests/setupAfterEnv.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'md'],
modulePathIgnorePatterns: ['/_site/'],

View File

@ -2,7 +2,7 @@ const { moduleNameMapper, transformIgnorePatterns } = require('./.jest');
// jest config for server render environment
module.exports = {
setupFiles: ['./tests/setup.js'],
setupFiles: ['./tests/setup.ts'],
setupFilesAfterEnv: ['./tests/setupAfterEnv.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'md'],
moduleNameMapper,

View File

@ -16,6 +16,26 @@ tag: vVERSION
---
## 5.12.4
`2023-12-19`
- 🐞 Fix DatePicker style in `cssVar` mode. [#46526](https://github.com/ant-design/ant-design/pull/46526)
## 5.12.3
`2023-12-18`
- 💄 Fix Tag that style would be missing in SSR. [#46500](https://github.com/ant-design/ant-design/pull/46500) [@MadCcc](https://github.com/MadCcc)
- 🐞 Upload `disabled` prop should not affect download icon. [#46454](https://github.com/ant-design/ant-design/pull/46454)
- 💄 Upload.Dragger add vertical padding style. [#46457](https://github.com/ant-design/ant-design/pull/46457)
- 🐞 Fix Upload actions color issue. [#46456](https://github.com/ant-design/ant-design/pull/46456)
- 🐞 Fix Form with `getValueProps` not working with value update. [#46445](https://github.com/ant-design/ant-design/pull/46445)
- 💄 Fix Checkbox style when customize `token.lineWidth`. [#46431](https://github.com/ant-design/ant-design/pull/46431)
- 🐞 Fix Select that custom token make `padding` broken. [#46427](https://github.com/ant-design/ant-design/pull/46427) [@MadCcc](https://github.com/MadCcc)
- 🐞 Fix Message that token overrides not work in `cssVar` mode. [#46415](https://github.com/ant-design/ant-design/pull/46415) [@MadCcc](https://github.com/MadCcc)
- 💄 Fix Flex component don't apply extra style. [#46404](https://github.com/ant-design/ant-design/pull/46404) [@li-jia-nan](https://github.com/li-jia-nan)
## 5.12.2
`2023-12-11`

View File

@ -16,6 +16,26 @@ tag: vVERSION
---
## 5.12.4
`2023-12-19`
- 🐞 修复 DatePicker 组件在 CSS 变量模式下的样式错乱问题。[#46526](https://github.com/ant-design/ant-design/pull/46526)
## 5.12.3
`2023-12-18`
- 💄 修复 Tag 组件在 SSR 场景下的样式丢失问题。[#46500](https://github.com/ant-design/ant-design/pull/46500) [@MadCcc](https://github.com/MadCcc)
- 🐞 Upload 的 `disabled` 属性不再对下载按钮生效。[#46454](https://github.com/ant-design/ant-design/pull/46454)
- 💄 Upload.Dragger 增加一个水平内边距。[#46457](https://github.com/ant-design/ant-design/pull/46457)
- 🐞 修复 Upload `actions` 的颜色问题。[#46456](https://github.com/ant-design/ant-design/pull/46456)
- 🐞 修复 Form 使用 `getValueProps` 展示值无法更新的问题。[#46445](https://github.com/ant-design/ant-design/pull/46445)
- 💄 修复 Checkbox 自定义 `token.lineWidth` 时勾选箭头错位问题。[#46431](https://github.com/ant-design/ant-design/pull/46431)
- 🐞 修复 Select 组件定制 token 会让 padding 失效的问题。[#46427](https://github.com/ant-design/ant-design/pull/46427) [@MadCcc](https://github.com/MadCcc)
- 🐞 修复 Message 在 `cssVar` 模式下覆盖组件 token 无效的问题。[#46415](https://github.com/ant-design/ant-design/pull/46415) [@MadCcc](https://github.com/MadCcc)
- 💄 Flex 组件不应该应用额外的样式。[#46404](https://github.com/ant-design/ant-design/pull/46404) [@li-jia-nan](https://github.com/li-jia-nan)
## 5.12.2
`2023-12-11`

View File

@ -6,20 +6,11 @@
一套企业级 UI 设计语言和 React 组件库。
[![CI status][github-action-image]][github-action-url]
[![codecov][codecov-image]][codecov-url]
[![NPM version][npm-image]][npm-url]
[![NPM downloads][download-image]][download-url]
[![CI status][github-action-image]][github-action-url] [![codecov][codecov-image]][codecov-url] [![NPM version][npm-image]][npm-url] [![NPM downloads][download-image]][download-url]
[![][bundlephobia-image]][bundlephobia-url]
[![][bundlesize-js-image]][unpkg-js-url]
[![FOSSA Status][fossa-image]][fossa-url]
[![][bundlephobia-image]][bundlephobia-url] [![][bundlesize-js-image]][unpkg-js-url] [![FOSSA Status][fossa-image]][fossa-url]
[![Follow Twitter][twitter-image]][twitter-url]
[![Renovate status][renovate-image]][renovate-dashboard-url]
[![][issues-helper-image]][issues-helper-url]
[![dumi][dumi-image]][dumi-url]
[![Issues need help][help-wanted-image]][help-wanted-url]
[![Follow Twitter][twitter-image]][twitter-url] [![Renovate status][renovate-image]][renovate-dashboard-url] [![][issues-helper-image]][issues-helper-url] [![dumi][dumi-image]][dumi-url] [![Issues need help][help-wanted-image]][help-wanted-url]
[更新日志](./CHANGELOG-zh-CN.md) · [报告问题][github-issues-url] · [特性需求][github-issues-url] · [English](./README.md) · 中文
@ -171,8 +162,6 @@ $ npm start
## 🤝 参与共建 [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com)
ome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com)
<!-- Copy-paste in your Readme.md file -->
<a href="https://next.ossinsight.io/widgets/official/compose-recent-active-contributors?repo_id=34526884&limit=30" target="_blank" style="display: block" align="center">

View File

@ -1,5 +1,7 @@
import { imageDemoTest } from '../../../tests/shared/imageTest';
describe('Carousel image', () => {
imageDemoTest('carousel');
imageDemoTest('carousel', {
ssr: true,
});
});

View File

@ -32,7 +32,7 @@ import type {
TriggerType,
} from './interface';
import useStyle from './style';
import { customizePrefixCls, genAlphaColor, generateColor, getAlphaColor } from './util';
import { genAlphaColor, generateColor, getAlphaColor } from './util';
export type ColorPickerProps = Omit<
RcColorPickerProps,
@ -91,6 +91,7 @@ const ColorPicker: CompoundedComponent = (props) => {
className,
size: customizeSize,
rootClassName,
prefixCls: customizePrefixCls,
styles,
disabledAlpha = false,
onFormatChange,

View File

@ -2,8 +2,6 @@ import type { ColorGenInput } from '@rc-component/color-picker';
import type { Color } from './color';
import { ColorFactory } from './color';
export const customizePrefixCls = 'ant-color-picker';
export const generateColor = (color: ColorGenInput<Color>): Color => {
if (color instanceof ColorFactory) {
return color;

View File

@ -12543,6 +12543,111 @@ exports[`ConfigProvider components Collapse prefixCls 1`] = `
</div>
`;
exports[`ConfigProvider components ColorPicker configProvider 1`] = `
<div
class="config-color-picker-trigger"
>
<div
class="config-color-picker-color-block"
>
<div
class="config-color-picker-color-block-inner"
style="background: rgb(22, 119, 255);"
/>
</div>
</div>
`;
exports[`ConfigProvider components ColorPicker configProvider componentDisabled 1`] = `
<div
class="config-color-picker-trigger config-color-picker-trigger-disabled"
>
<div
class="config-color-picker-color-block"
>
<div
class="config-color-picker-color-block-inner"
style="background: rgb(22, 119, 255);"
/>
</div>
</div>
`;
exports[`ConfigProvider components ColorPicker configProvider componentSize large 1`] = `
<div
class="config-color-picker-trigger config-color-picker-lg"
>
<div
class="config-color-picker-color-block"
>
<div
class="config-color-picker-color-block-inner"
style="background: rgb(22, 119, 255);"
/>
</div>
</div>
`;
exports[`ConfigProvider components ColorPicker configProvider componentSize middle 1`] = `
<div
class="config-color-picker-trigger"
>
<div
class="config-color-picker-color-block"
>
<div
class="config-color-picker-color-block-inner"
style="background: rgb(22, 119, 255);"
/>
</div>
</div>
`;
exports[`ConfigProvider components ColorPicker configProvider componentSize small 1`] = `
<div
class="config-color-picker-trigger config-color-picker-sm"
>
<div
class="config-color-picker-color-block"
>
<div
class="config-color-picker-color-block-inner"
style="background: rgb(22, 119, 255);"
/>
</div>
</div>
`;
exports[`ConfigProvider components ColorPicker normal 1`] = `
<div
class="ant-color-picker-trigger"
>
<div
class="ant-color-picker-color-block"
>
<div
class="ant-color-picker-color-block-inner"
style="background: rgb(22, 119, 255);"
/>
</div>
</div>
`;
exports[`ConfigProvider components ColorPicker prefixCls 1`] = `
<div
class="prefix-ColorPicker-trigger"
>
<div
class="prefix-ColorPicker-color-block"
>
<div
class="prefix-ColorPicker-color-block-inner"
style="background: rgb(22, 119, 255);"
/>
</div>
</div>
`;
exports[`ConfigProvider components DatePicker DatePicker configProvider 1`] = `
<div>
<div

View File

@ -1,7 +1,9 @@
import React from 'react';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import React from 'react';
import ConfigProvider from '..';
import { render } from '../../../tests/utils';
import Alert from '../../alert';
import Anchor from '../../anchor';
import AutoComplete from '../../auto-complete';
@ -16,6 +18,7 @@ import Carousel from '../../carousel';
import Cascader from '../../cascader';
import Checkbox from '../../checkbox';
import Collapse from '../../collapse';
import ColorPicker from '../../color-picker';
import DatePicker from '../../date-picker';
import Divider from '../../divider';
import Drawer from '../../drawer';
@ -39,7 +42,6 @@ import Select from '../../select';
import Skeleton from '../../skeleton';
import type { SliderTooltipProps } from '../../slider';
import Slider from '../../slider';
import { render } from '../../../tests/utils';
import Spin from '../../spin';
import Statistic from '../../statistic';
import Steps from '../../steps';
@ -233,6 +235,9 @@ describe('ConfigProvider', () => {
</Collapse>
));
// ColorPicker
testPair('ColorPicker', (props) => <ColorPicker {...props} />);
// DatePicker
describe('DatePicker', () => {
testPair('DatePicker', (props) => (

View File

@ -1,5 +1,6 @@
import dayjs from 'dayjs';
import * as React from 'react';
import dayjs from 'dayjs';
import demoTest, { rootPropsTest } from '../../../tests/shared/demoTest';
demoTest('date-picker', { skip: ['locale.tsx', 'component-token.tsx'], testRootProps: false });
@ -10,7 +11,7 @@ rootPropsTest('date-picker', (DatePicker, props) => <DatePicker {...props} value
});
rootPropsTest(
'date-picker',
['date-picker', 'RangePicker'],
(DatePicker, props) => <DatePicker.RangePicker {...props} value={dayjs()} />,
{
findRootElements: () => document.querySelectorAll('.ant-picker-range, .ant-picker-dropdown'),

View File

@ -123,7 +123,7 @@ Form field component for data bidirectional binding, validation, layout, and so
| dependencies | Set the dependency field. See [below](#dependencies) | [NamePath](#namepath)\[] | - | |
| extra | The extra prompt message. It is similar to help. Usage example: to display error message and prompt message at the same time | ReactNode | - | |
| getValueFromEvent | Specify how to get value from event or other onChange arguments | (..args: any\[]) => any | - | |
| getValueProps | Additional props with sub component | (value: any) => any | - | 4.2.0 |
| getValueProps | Additional props with sub component (It's not recommended to generate dynamic function prop by `getValueProps`. Please pass it to child component directly) | (value: any) => Record<string, any> | - | 4.2.0 |
| hasFeedback | Used with `validateStatus`, this option specifies the validation status icon. Recommended to be used only with `Input`. Also, It can get feedback icons via icons prop. | boolean \| { icons: [FeedbackIcons](#feedbackicons) } | false | icons: 5.9.0 |
| help | The prompt message. If not provided, the prompt message will be generated by the validation rule. | ReactNode | - | |
| hidden | Whether to hide Form.Item (still collect and validate value) | boolean | false | 4.4.0 |

View File

@ -124,7 +124,7 @@ const validateMessages = {
| dependencies | 设置依赖字段,说明[见下](#dependencies) | [NamePath](#namepath)\[] | - | |
| extra | 额外的提示信息,和 `help` 类似,当需要错误信息和提示文案同时出现时,可以使用这个。 | ReactNode | - | |
| getValueFromEvent | 设置如何将 event 的值转换成字段值 | (..args: any\[]) => any | - | |
| getValueProps | 为子元素添加额外的属性 | (value: any) => any | - | 4.2.0 |
| getValueProps | 为子元素添加额外的属性 (不建议通过 `getValueProps` 生成动态函数 prop请直接将其传递给子组件) | (value: any) => Record<string, any> | - | 4.2.0 |
| hasFeedback | 配合 `validateStatus` 属性使用,展示校验状态图标,建议只配合 Input 组件使用 此外,它还可以通过 Icons 属性获取反馈图标。 | boolean \| { icons: [FeedbackIcons](#feedbackicons) } | false | icons: 5.9.0 |
| help | 提示信息,如不设置,则会根据校验规则自动生成 | ReactNode | - | |
| hidden | 是否隐藏字段(依然会收集和校验字段) | boolean | false | 4.4.0 |

View File

@ -125,7 +125,7 @@ Supports all props of `Input`.
| Property | Description | Type | Default | Version |
| --- | --- | --- | --- | --- |
| visible | Whether the password is show or hide | boolean | false | 4.24.0 |
| onVisibleChange | Callback executed when visibility of the password is changed | boolean | - | 4.24.0 |
| onVisibleChange | Callback executed when visibility of the password is changed | (visible) => void | - | 4.24.0 |
#### Input Methods

View File

@ -124,9 +124,9 @@ interface CountConfig {
#### VisibilityToggle
| Property | Description | Type | Default | Version |
| --------------- | -------------------- | ------- | ------- | ------- |
| --------------- | -------------------- | ----------------- | ------- | ------- |
| visible | 用于手动控制密码显隐 | boolean | false | 4.24 |
| onVisibleChange | 显隐密码的回调 | boolean | - | 4.24 |
| onVisibleChange | 显隐密码的回调 | (visible) => void | - | 4.24 |
#### Input Methods

View File

@ -1,5 +1,7 @@
import { imageDemoTest } from '../../../tests/shared/imageTest';
describe('Progress image', () => {
imageDemoTest('progress');
imageDemoTest('progress', {
ssr: true,
});
});

View File

@ -12,9 +12,9 @@ const customIcons: Record<number, React.ReactNode> = {
const App: React.FC = () => (
<>
<Rate defaultValue={2} character={({ index }: { index: number }) => index + 1} />
<Rate defaultValue={2} character={({ index = 0 }) => index + 1} />
<br />
<Rate defaultValue={3} character={({ index }: { index: number }) => customIcons[index + 1]} />
<Rate defaultValue={3} character={({ index = 0 }) => customIcons[index + 1]} />
</>
);

View File

@ -1,4 +1,5 @@
import type { CSSInterpolation, CSSObject } from '@ant-design/cssinjs';
import type { AliasToken } from '../theme/internal';
import type { TokenWithCommonCls } from '../theme/util/genComponentStyleHook';
import type { ArrowToken } from './roundedArrow';
@ -24,7 +25,9 @@ export function getArrowOffsetToken(options: {
}
function isInject(valid: boolean, code: CSSObject): CSSObject {
if (!valid) return {};
if (!valid) {
return {};
}
return code;
}

View File

@ -0,0 +1,127 @@
---
title: A build ghost
date: 2023-12-20
author: zombieJ
---
In the maintenance of antd-mobile, We meet an annoying ghost. It rarely appears when building locally, but it almost always appears in the github workflow. After a lot of tossing, We finally found its trace.
### CI Failed...again
For antd-mobile's CI, there is a task to check the build artifacts, which will prompt the file size changes. But in recent months, this task often fails to build, as shown in the following figure:
![CI failed](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*XSAESJ3_HWgAAAAAAAAAAAAADrJ8AQ/original)
Check the log, we will get the error message of CSS file:
![Unknown word](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*2ybATYq9l2oAAAAAAAAAAAAADrJ8AQ/original)
It seems that the error occurred when building 2x style (antd-mobile will generate 2x style for high-definition screen):
```log
[09:44:16] Using gulpfile ~/work/ant-design-mobile/ant-design-mobile/gulpfile.js
[09:44:16] Starting 'default'...
[09:44:16] Starting 'clean'...
[09:44:17] Finished 'clean' after 286 ms
[09:44:17] Starting 'buildES'...
[09:44:26] Finished 'buildES' after 8.77 s
[09:44:26] Starting 'buildCJS'...
[09:44:27] Finished 'buildCJS' after 1.72 s
[09:44:27] Starting 'buildDeclaration'...
[09:44:27] Starting 'buildStyle'...
[09:44:28] Finished 'buildStyle' after 682 ms
[09:44:34] Finished 'buildDeclaration' after 6.5 s
[09:44:34] Starting 'copyAssets'...
[09:44:34] Finished 'copyAssets' after 2.37 ms
[09:44:34] Starting 'copyMetaFiles'...
[09:44:34] Finished 'copyMetaFiles' after 4.64 ms
[09:44:34] Starting 'generatePackageJSON'...
[09:44:34] Finished 'generatePackageJSON' after 2.72 ms
[09:44:34] Starting 'buildBundles'...
[09:44:45] Finished 'buildBundles' after 11 s
[09:44:45] Starting 'init2xFolder'...
[09:44:46] Finished 'init2xFolder' after 811 ms
[09:44:46] Starting 'build2xCSS'...
[09:44:46] 'build2xCSS' errored after 126 ms
[09:44:46] CssSyntaxError in plugin "gulp-postcss"
```
The `style.css` of `build2xCSS` comes from the artifact of `buildStyle`, so it can be determined that there is a problem in the `buildStyle` task. After checking the corresponding file `/lib/bundle/style.css`, we found the following content:
![Break Lines](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*5NqFR6_nkhwAAAAAAAAAAAAADrJ8AQ/original)
The first line of `style.css` is the compressed style, and then the incomplete uncompressed style. Compared with the successful artifact, it will be found that the style after the second line is unexpected:
![Success Style](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*xnDRT5SDVvMAAAAAAAAAAAAADrJ8AQ/original)
Check the uncompressed content, we will find that these contents already exist in the previous compressed content:
![Duplicated Content](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*wShGRJ16U1AAAAAAAAAAAAAADrJ8AQ/original)
It is speculated that the uncompressed content was generated first during the build, and then the compressed operation was performed. But there is an asynchronous problem, the second task started to execute before the first task was completed, resulting in the duplication of content. What's even more bizarre is that if it is an asynchronous problem, the error file content generated on CI is surprisingly consistent. No matter how many times it is built, as long as it fails, it must be the same content.
### Concurrent problem
Check the `gulpfile.js` file, we found that `buildStyle` uses vite to build. Considering that it may be a problem with the build version, so we upgraded vite from `3.x` to `5.x`, but the problem still exists. So check the relevant configuration:
```tsx
{
root: process.cwd(),
mode: env,
logLevel: 'silent',
define: { 'process.env.NODE_ENV': `"${env}"` },
build: {
cssTarget: 'chrome61',
lib: {
formats,
...
},
rollupOptions: {
output: {
dir: './lib/bundle',
globals: {
'react': 'React',
'react-dom': 'ReactDOM',
},
},
},
minify: isProd ? 'esbuild' : false,
},
}
```
Though closing the `logLevel: 'silent'` configuration, we can see more log content after rebuilding:
![Bundle Result](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*efjVR4DG_ysAAAAAAAAAAAAADrJ8AQ/original)
We are close. When building, antd-mobile will create three copies of `es`, `cjs`, and `umd` through `lib.formats`. And each `format` will generate a `style.css` file. If it is just to overwrite the file, it should only waste extra build resources, and the compressed `style.css` will always be overwritten, and there should be no problem of simultaneous overwriting. Let's check the part that calls vite to build:
```tsx
async function buildBundles(cb) {
const envs = ['development', 'production'];
const configs = envs.map((env) =>
getViteConfigForPackage({
env,
formats: ['es', 'cjs', 'umd'],
external: ['react', 'react-dom'],
}),
);
await Promise.all(configs.map((config) => vite.build(config)));
cb && cb();
}
```
That's it. `Promise.all` is used to build concurrently, and vite's build is asynchronous. This causes a competition problem for `style.css`. The rollup called by vite will clean up the files and then perform write operations. Since the compressed style needs to be uglified, it is always slower than the uncompressed version. When rollup has finished cleaning and starts writing files, although the first part of the uncompressed version is deleted due to cleaning, the subsequent content is still written, while the compressed version starts writing from the beginning. When both are written, an error will occur and the content will be consistent under each CI build. The fix is also very simple, just change it to sequential execution:
```tsx
for (const config of configs) {
await vite.build(config);
}
```
(Of course, subsequent optimizations are also needed for the script. Skip the unnecessary `style.css` generation)
### That's all
With the performance changes of github CI, the ghost that was originally difficult to encounter has become stable and reproducible, which is quite interesting. This also gives us the opportunity to locate the problem.

View File

@ -0,0 +1,127 @@
---
title: 一个构建的幽灵
date: 2023-12-20
author: zombieJ
---
在 antd-mobile 的维护过程中,遇到了一个恼人的幽灵。它在本地构建时几乎不会出现,但是在 github 的 workflow 中,却几乎每次都会出现。在经过一番折腾后,终于找到了它的踪迹。
### CI 又挂了
在 antd-mobile 的 CI 中,有一个任务会对构建产物进行检查,会对文件大小变化进行提示。但是这个任务在最近几个月中,经常会出现构建失败的情况,如下图所示:
![CI failed](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*XSAESJ3_HWgAAAAAAAAAAAAADrJ8AQ/original)
查看日志,会得到 CSS 文件报错的信息:
![Unknown word](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*2ybATYq9l2oAAAAAAAAAAAAADrJ8AQ/original)
从构建流程看,似乎是在 2x 构建时报错antd-mobile 会额外打一份 2x 的样式以适配高清屏):
```log
[09:44:16] Using gulpfile ~/work/ant-design-mobile/ant-design-mobile/gulpfile.js
[09:44:16] Starting 'default'...
[09:44:16] Starting 'clean'...
[09:44:17] Finished 'clean' after 286 ms
[09:44:17] Starting 'buildES'...
[09:44:26] Finished 'buildES' after 8.77 s
[09:44:26] Starting 'buildCJS'...
[09:44:27] Finished 'buildCJS' after 1.72 s
[09:44:27] Starting 'buildDeclaration'...
[09:44:27] Starting 'buildStyle'...
[09:44:28] Finished 'buildStyle' after 682 ms
[09:44:34] Finished 'buildDeclaration' after 6.5 s
[09:44:34] Starting 'copyAssets'...
[09:44:34] Finished 'copyAssets' after 2.37 ms
[09:44:34] Starting 'copyMetaFiles'...
[09:44:34] Finished 'copyMetaFiles' after 4.64 ms
[09:44:34] Starting 'generatePackageJSON'...
[09:44:34] Finished 'generatePackageJSON' after 2.72 ms
[09:44:34] Starting 'buildBundles'...
[09:44:45] Finished 'buildBundles' after 11 s
[09:44:45] Starting 'init2xFolder'...
[09:44:46] Finished 'init2xFolder' after 811 ms
[09:44:46] Starting 'build2xCSS'...
[09:44:46] 'build2xCSS' errored after 126 ms
[09:44:46] CssSyntaxError in plugin "gulp-postcss"
```
`build2xCSS``style.css` 来源于 `buildStyle` 的产物,所以可以确定是 `buildStyle` 任务中出现了问题。在查看对应的文件 `/lib/bundle/style.css` 后,发现了如下的内容:
![Break Lines](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*5NqFR6_nkhwAAAAAAAAAAAAADrJ8AQ/original)
`style.css` 第一行为压缩的样式,而后是不完整的未压缩的样式。对比成功的产物会发现第二行往后的样式是非预期的内容:
![Success Style](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*xnDRT5SDVvMAAAAAAAAAAAAADrJ8AQ/original)
而根据未压缩的内容进行查询,会发现这些内容在之前的压缩内容中已经存在了:
![Duplicated Content](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*wShGRJ16U1AAAAAAAAAAAAAADrJ8AQ/original)
因而猜测可能是构建时先生成了未压缩的内容然后又进行了压缩操作。但是又存在异步问题第二个任务在第一个未完成时就开始执行了导致了内容的重复。更诡异的是如果是异步问题CI 上生成的错误文件内容却出奇的一致。无论构建多少次,只要是失败的就必定是相同的内容。
### 并发问题
在查看了 `gulpfile.js` 文件后,发现 `buildStyle` 使用的是 vite 构建。考虑到可能是构建版本的问题,所以将 vite 的版本从 `3.x` 升级到了 `5.x`,但是问题依旧存在。于是又看了一下相关配置:
```tsx
{
root: process.cwd(),
mode: env,
logLevel: 'silent',
define: { 'process.env.NODE_ENV': `"${env}"` },
build: {
cssTarget: 'chrome61',
lib: {
formats,
...
},
rollupOptions: {
output: {
dir: './lib/bundle',
globals: {
'react': 'React',
'react-dom': 'ReactDOM',
},
},
},
minify: isProd ? 'esbuild' : false,
},
}
```
通过关闭 `logLevel: 'silent'` 配置后再次构建,我们可以看到更多的日志内容:
![Bundle Result](https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*efjVR4DG_ysAAAAAAAAAAAAADrJ8AQ/original)
看来接近答案了antd-mobile 在构建时会通过 `lib.formats` 创建 `es`、`cjs`、`umd` 三份副本。而每个 `format` 都会生成一次 `style.css` 文件。如果仅是不断覆盖文件,那应该只是额外的浪费了构建资源而已,最后总是会被压缩的 `style.css` 覆盖掉,不应该出现同时覆盖的问题。于是去看了一下调用 vite 构建的部分:
```tsx
async function buildBundles(cb) {
const envs = ['development', 'production'];
const configs = envs.map((env) =>
getViteConfigForPackage({
env,
formats: ['es', 'cjs', 'umd'],
external: ['react', 'react-dom'],
}),
);
await Promise.all(configs.map((config) => vite.build(config)));
cb && cb();
}
```
原来是使用了 `Promise.all` 来并发构建,而 vite 的构建是异步的。这使得 `style.css` 存在竞争问题。vite 调用的 rollup 会对文件进行清除,然后进行写操作。由于压缩样式需要进行 uglify所以它总是慢于非压缩版本。当 rollup 都执行完清理操作开始写文件后,非压缩版本虽然前面一部分由于清理被删除但是后续内容仍然继续被写入,而压缩版本则从头开始写入。当两者都写入完毕后,就会出现错误并且内容却在每次 CI 构建下都一致的情况。修复也很简单,直接改成顺序执行即可:
```tsx
for (const config of configs) {
await vite.build(config);
}
```
(当然,后续还需要对脚本进行优化。使其跳过非必要的 `style.css` 样式生成)
### 以上
随着 github CI 的性能变化,原本很难遇到的幽灵反而变得可以稳定重现,颇为有趣。从而也使得我们有机会可以定位到问题之所在。

View File

@ -1,6 +1,6 @@
{
"name": "antd",
"version": "5.12.2",
"version": "5.12.4",
"description": "An enterprise-class UI design language and React components implementation",
"keywords": [
"ant",
@ -93,7 +93,7 @@
"pretest": "npm run version && npm run component-changelog",
"test": "jest --config .jest.js --no-cache",
"test-all": "sh -e ./scripts/test-all.sh",
"test-image": "jest --config .jest.image.js --no-cache -i -u",
"test-image": "jest --config .jest.image.js --no-cache -i -u --forceExit",
"test-node": "npm run version && jest --config .jest.node.js --no-cache",
"test:update": "jest --config .jest.js --no-cache -u",
"token-meta": "tsx scripts/generate-token-meta.ts",
@ -154,7 +154,7 @@
"rc-table": "~7.36.0",
"rc-tabs": "~12.15.0",
"rc-textarea": "~1.6.3",
"rc-tooltip": "~6.1.2",
"rc-tooltip": "~6.1.3",
"rc-tree": "~5.8.2",
"rc-tree-select": "~5.15.0",
"rc-upload": "~4.3.5",
@ -241,7 +241,7 @@
"eslint-plugin-markdown": "^3.0.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-unicorn": "^49.0.0",
"eslint-plugin-unicorn": "^50.0.1",
"fast-glob": "^3.3.2",
"fetch-jsonp": "^1.3.0",
"fs-extra": "^11.1.1",
@ -275,7 +275,7 @@
"node-fetch": "^3.3.2",
"node-notifier": "^10.0.1",
"nprogress": "^0.2.0",
"open": "^9.1.0",
"open": "^10.0.0",
"pixelmatch": "^5.3.0",
"pngjs": "^7.0.0",
"prettier": "^3.1.0",

View File

@ -52,6 +52,7 @@ const DEPRECIATED_VERSION = {
'5.11.1': ['https://github.com/ant-design/ant-design/issues/45883'],
'5.11.2': ['https://github.com/ant-design/ant-design/issues/46005'],
'5.11.4': ['https://github.com/ant-design/ant-design/pull/46103'],
'5.12.3': ['https://github.com/ant-design/ant-design/issues/46525'],
} as const;
function matchDeprecated(v: string) {

View File

@ -1,23 +1,22 @@
/* eslint-disable compat/compat */
/* eslint-disable no-console, no-await-in-loop, import/no-extraneous-dependencies, lodash/import-scope, no-restricted-syntax */
import path from 'path';
import { assert } from 'console';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { Readable } from 'stream';
import { finished } from 'stream/promises';
import { remark } from 'remark';
import remarkHtml from 'remark-html';
import remarkGfm from 'remark-gfm';
import minimist from 'minimist';
import tar from 'tar';
import fse from 'fs-extra';
import chalk from 'chalk';
import fse from 'fs-extra';
import _ from 'lodash';
import minimist from 'minimist';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import remarkHtml from 'remark-html';
import sharp from 'sharp';
import { assert } from 'console';
import tar from 'tar';
const ALI_OSS_BUCKET = 'antd-visual-diff';
@ -63,7 +62,7 @@ const compareScreenshots = async (
diffPng.pack().pipe(fs.createWriteStream(diffImagePath));
}
return (mismatchedPixels / (targetWidth * targetHeight)) * 100;
return mismatchedPixels / (targetWidth * targetHeight);
};
const readPngs = (dir: string) => fs.readdirSync(dir).filter((n) => n.endsWith('.png'));
@ -108,6 +107,10 @@ async function downloadBaseSnapshots(ref: string, targetDir: string) {
interface IBadCase {
type: 'removed' | 'changed';
filename: string;
/**
* 0 - 1
*/
weight: number;
}
function md2Html(md: string) {
@ -135,12 +138,14 @@ function generateReport(
): [string, string] {
const publicPath = isLocalEnv ? path.resolve(__dirname, '../..') : `${ossDomain}/pr-${prId}`;
const passed = badCases.length === 0;
const commonHeader = `
## Visual Regression Report for PR #${prId}
## Visual Regression Report for PR #${prId} ${passed ? 'Passed ✅' : 'Failed ❌'}
> **Target branch:** ${targetBranch} (${targetRef})
`.trim();
if (badCases.length === 0) {
if (passed) {
const mdStr = [
commonHeader,
'------------------------',
@ -152,10 +157,7 @@ function generateReport(
const htmlReportLink = `${publicPath}/visualRegressionReport/report.html`;
const addonFullReportDesc = `\n\nToo many visual-regression diffs found, please check <a href="${htmlReportLink}" target="_blank">Full Report</a> for details`;
// github action pr comment has limit of 65536 4-byte unicode characters
const limit = 65536 - addonFullReportDesc.length;
const addonFullReportDesc = `\n\nCheck <a href="${htmlReportLink}" target="_blank">Full Report</a> for details`;
let reportMdStr = `
${commonHeader}
@ -168,7 +170,7 @@ ${commonHeader}
let fullVersionMd = reportMdStr;
let addonFullReportDescAdded = false;
let diffCount = 0;
for (const badCase of badCases) {
const { filename, type } = badCase;
@ -193,16 +195,15 @@ ${commonHeader}
lineReportMdStr += ' |\n';
}
if (lineReportMdStr) {
if (reportMdStr.length + lineReportMdStr.length < limit) {
diffCount += 1;
if (diffCount <= 10) {
reportMdStr += lineReportMdStr;
} else if (!addonFullReportDescAdded) {
reportMdStr += addonFullReportDesc;
addonFullReportDescAdded = true;
}
fullVersionMd += lineReportMdStr;
}
}
reportMdStr += addonFullReportDesc;
// convert fullVersionMd to html
return [reportMdStr, md2Html(fullVersionMd)];
@ -213,6 +214,7 @@ async function boot() {
const baseImgSourceDir = path.resolve(__dirname, `../../imageSnapshots-${targetBranch}`);
/* --- prepare stage --- */
console.log(
chalk.green(
`Preparing image snapshots from latest \`${targetBranch}\` branch for pr \`${prId}\`\n`,
@ -220,11 +222,11 @@ async function boot() {
);
await fse.ensureDir(baseImgSourceDir);
const targetRef = await getBranchLatestRef(targetBranch);
assert(targetRef, `Missing ref from ${targetBranch}`);
const targetCommitSha = await getBranchLatestRef(targetBranch);
assert(targetCommitSha, `Missing commit sha from ${targetBranch}`);
if (!isLocalEnv) {
await downloadBaseSnapshots(targetRef, baseImgSourceDir);
await downloadBaseSnapshots(targetCommitSha, baseImgSourceDir);
} else if (!fse.existsSync(baseImgSourceDir)) {
console.log(
chalk.yellow(
@ -252,24 +254,8 @@ async function boot() {
console.log('\n');
const baseImgFileList = readPngs(baseImgSourceDir);
const currentImgFileList = readPngs(currentImgSourceDir);
const deletedImgs = _.difference(baseImgFileList, currentImgFileList);
if (deletedImgs.length) {
console.log(
chalk.red('⛔️ Missing images compare to %s:\n%s'),
targetBranch,
prettyList(deletedImgs),
);
console.log('\n');
}
// ignore new images
const newImgs = _.difference(currentImgFileList, baseImgFileList);
if (newImgs.length) {
console.log(chalk.green('🆕 Added images:\n'), prettyList(newImgs));
console.log('\n');
}
/* --- compare stage --- */
const badCases: IBadCase[] = [];
// compare cssinjs and css-var png from pr
@ -295,6 +281,7 @@ async function boot() {
badCases.push({
type: 'removed',
filename: compareImgName,
weight: 1,
});
await fse.copy(baseImgPath, path.join(baseImgReportDir, compareImgName));
continue;
@ -310,7 +297,7 @@ async function boot() {
console.log(
'Mismatched pixels for:',
chalk.yellow(compareImgName),
`${mismatchedPxPercent.toFixed(2)}%\n`,
`${(mismatchedPxPercent * 100).toFixed(2)}%\n`,
);
// copy compare imgs(x2) to report dir
await fse.copy(baseImgPath, path.join(baseImgReportDir, compareImgName));
@ -319,6 +306,7 @@ async function boot() {
badCases.push({
type: 'changed',
filename: compareImgName,
weight: mismatchedPxPercent,
});
} else {
console.log('Passed for: %s\n', chalk.green(compareImgName));
@ -326,15 +314,16 @@ async function boot() {
}
}
if (badCases.length) {
console.log(chalk.red('⛔️ Failed cases:\n'), prettyList(badCases.map((i) => i.filename)));
console.log('\n');
}
/* --- generate report stage --- */
const jsonl = badCases.map((i) => JSON.stringify(i)).join('\n');
// write jsonl and markdown report to diffImgDir
await fse.writeFile(path.join(reportDir, './report.jsonl'), jsonl);
const [reportMdStr, reportHtmlStr] = generateReport(badCases, targetBranch, targetRef, prId);
const [reportMdStr, reportHtmlStr] = generateReport(
badCases,
targetBranch,
targetCommitSha,
prId,
);
await fse.writeFile(path.join(reportDir, './report.md'), reportMdStr);
const htmlTemplate = await fse.readFile(path.join(__dirname, './report-template.html'), 'utf8');
@ -353,6 +342,33 @@ async function boot() {
},
await fse.readdir(reportDir),
);
const currentImgFileList = readPngs(currentImgSourceDir);
/* --- text report stage --- */
console.log(
chalk.blue(`📊 Text report from pr #${prId} comparing to ${targetBranch}@${targetCommitSha}\n`),
);
// new images
const newImgs = _.difference(currentImgFileList, baseImgFileList);
if (newImgs.length) {
console.log(chalk.green(`🆕 ${newImgs.length} images added from this pr`));
console.log(chalk.green('🆕 Added images list:\n'));
console.log(prettyList(newImgs));
console.log('\n');
}
if (!badCases.length) {
console.log(chalk.green('🎉 All passed!'));
console.log('\n');
return;
}
const sortedBadCases = badCases.sort((a, b) => b.weight - a.weight);
console.log(chalk.red('⛔️ Failed cases:\n'));
console.log(prettyList(sortedBadCases.map((i) => `[${i.type}] ${i.filename}`)));
console.log('\n');
// let job failed
process.exit(1);
}
boot();

View File

@ -16,6 +16,7 @@
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
@ -26,6 +27,20 @@
vertical-align: top;
}
td img {
max-width: 100%;
}
th,
td {
width: 10%;
}
th+th,
td+td {
width: 30%;
}
th {
background-color: #f2f2f2;
}

View File

@ -1,5 +1,9 @@
/* eslint-disable no-console */
const util = require('util');
/* eslint-disable no-console, import/prefer-default-export */
import util from 'util';
import type { DOMWindow } from 'jsdom';
// import { fillWindowEnv } from './utils';
const React = require('react');
// eslint-disable-next-line no-console
@ -20,17 +24,21 @@ console.error = (...args) => {
}
};
/* eslint-disable global-require */
if (typeof window !== 'undefined') {
global.window.resizeTo = (width, height) => {
global.window.innerWidth = width || global.window.innerWidth;
global.window.innerHeight = height || global.window.innerHeight;
global.window.dispatchEvent(new Event('resize'));
type Writeable<T> = { -readonly [P in keyof T]: T[P] };
// This function can not move to external file since jest setup not support
export function fillWindowEnv(window: Window | DOMWindow) {
const win = window as Writeable<Window> & typeof globalThis;
win.resizeTo = (width, height) => {
win.innerWidth = width || win.innerWidth;
win.innerHeight = height || win.innerHeight;
win.dispatchEvent(new Event('resize'));
};
global.window.scrollTo = () => {};
win.scrollTo = () => {};
// ref: https://github.com/ant-design/ant-design/issues/18774
if (!window.matchMedia) {
Object.defineProperty(global.window, 'matchMedia', {
if (!win.matchMedia) {
Object.defineProperty(win, 'matchMedia', {
writable: true,
configurable: true,
value: jest.fn((query) => ({
@ -44,11 +52,19 @@ if (typeof window !== 'undefined') {
// Fix css-animation or rc-motion deps on these
// https://github.com/react-component/motion/blob/9c04ef1a210a4f3246c9becba6e33ea945e00669/src/util/motion.ts#L27-L35
// https://github.com/yiminghe/css-animation/blob/a5986d73fd7dfce75665337f39b91483d63a4c8c/src/Event.js#L44
window.AnimationEvent = window.AnimationEvent || window.Event;
window.TransitionEvent = window.TransitionEvent || window.Event;
win.AnimationEvent = win.AnimationEvent || win.Event;
win.TransitionEvent = win.TransitionEvent || win.Event;
// ref: https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
// ref: https://github.com/jsdom/jsdom/issues/2524
Object.defineProperty(window, 'TextEncoder', { writable: true, value: util.TextEncoder });
Object.defineProperty(window, 'TextDecoder', { writable: true, value: util.TextDecoder });
Object.defineProperty(win, 'TextEncoder', { writable: true, value: util.TextEncoder });
Object.defineProperty(win, 'TextDecoder', { writable: true, value: util.TextDecoder });
}
/* eslint-disable global-require */
if (typeof window !== 'undefined') {
fillWindowEnv(window);
}
global.requestAnimationFrame = global.requestAnimationFrame || global.setTimeout;
global.cancelAnimationFrame = global.cancelAnimationFrame || global.clearTimeout;

View File

@ -1,15 +1,20 @@
import path from 'path';
import React from 'react';
// Reference: https://github.com/ant-design/ant-design/pull/24003#discussion_r427267386
// eslint-disable-next-line import/no-unresolved
import { createCache, extractStyle, StyleProvider } from '@ant-design/cssinjs';
import dayjs from 'dayjs';
import path from 'path';
import { globSync } from 'glob';
import { configureToMatchImageSnapshot } from 'jest-image-snapshot';
import { JSDOM } from 'jsdom';
import MockDate from 'mockdate';
import ReactDOMServer from 'react-dom/server';
import { App, ConfigProvider, theme } from '../../components';
import { fillWindowEnv } from '../setup';
import { render } from '../utils';
jest.mock('../../components/grid/hooks/useBreakpoint', () => () => ({}));
const toMatchImageSnapshot = configureToMatchImageSnapshot({
customSnapshotsDir: `${process.cwd()}/imageSnapshots`,
@ -27,6 +32,7 @@ const themes = {
interface ImageTestOptions {
onlyViewport?: boolean;
splitTheme?: boolean;
ssr?: boolean;
}
// eslint-disable-next-line jest/no-export
@ -35,6 +41,74 @@ export default function imageTest(
identifier: string,
options: ImageTestOptions,
) {
let doc: Document;
let container: HTMLDivElement;
beforeAll(() => {
const dom = new JSDOM('<!DOCTYPE html><body></body></p>', {
url: 'http://localhost/',
});
const win = dom.window;
doc = win.document;
(global as any).window = win;
// Fill env
const keys = [
...Object.keys(win),
'HTMLElement',
'SVGElement',
'ShadowRoot',
'Element',
'File',
'Blob',
].filter((key) => !(global as any)[key]);
keys.forEach((key) => {
(global as any)[key] = win[key];
});
// Fake Resize Observer
global.ResizeObserver = function FakeResizeObserver() {
return {
observe() {},
unobserve() {},
disconnect() {},
};
} as any;
// Fake promise not called
global.fetch = function mockFetch() {
return {
then() {
return this;
},
catch() {
return this;
},
finally() {
return this;
},
};
} as any;
// Fake matchMedia
win.matchMedia = () =>
({
matches: false,
addListener: jest.fn(),
removeListener: jest.fn(),
}) as any;
// Fill window
fillWindowEnv(win);
});
beforeEach(() => {
doc.body.innerHTML = `<div id="root"></div>`;
container = doc.querySelector<HTMLDivElement>('#root')!;
});
function test(name: string, suffix: string, themedComponent: React.ReactElement) {
it(name, async () => {
await jestPuppeteer.resetPage();
@ -55,14 +129,30 @@ export default function imageTest(
const cache = createCache();
const emptyStyleHolder = doc.createElement('div');
const element = (
<StyleProvider cache={cache}>
<StyleProvider cache={cache} container={emptyStyleHolder}>
<App>{themedComponent}</App>
</StyleProvider>
);
const html = ReactDOMServer.renderToString(element);
const styleStr = extractStyle(cache);
let html: string;
let styleStr: string;
if (options.ssr) {
html = ReactDOMServer.renderToString(element);
styleStr = extractStyle(cache);
} else {
const { unmount } = render(element, {
container,
});
html = container.innerHTML;
styleStr = extractStyle(cache);
// We should extract style before unmount
unmount();
}
await page.evaluate(
(innerHTML, ssrStyle) => {
@ -141,6 +231,8 @@ type Options = {
skip?: boolean | string[];
onlyViewport?: boolean | string[];
splitTheme?: boolean | string[];
/** Use SSR render instead. Only used when the third part deps component */
ssr?: boolean;
};
// eslint-disable-next-line jest/no-export
@ -168,6 +260,7 @@ export function imageDemoTest(component: string, options: Options = {}) {
splitTheme:
options.splitTheme === true ||
(Array.isArray(options.splitTheme) && options.splitTheme.some((c) => file.endsWith(c))),
ssr: options.ssr,
});
});
});

View File

@ -1,5 +1,6 @@
/* eslint-disable global-require, import/no-dynamic-require, jest/no-export */
import React from 'react';
import ConfigProvider from '../../components/config-provider';
import { render, waitFakeTimer } from '../utils';
import { TriggerMockContext } from './demoTestContext';
@ -20,14 +21,17 @@ function isSingleNode(node: any): node is Element {
}
export default function rootPropsTest(
component: string,
component: string | string[],
customizeRender?: (
component: React.ComponentType<any> & Record<string, any>,
props: any,
) => React.ReactNode,
options?: Options,
) {
const Component = require(`../../components/${component}`).default as any;
const componentNames = Array.isArray(component) ? component : [component];
const [componentName, subComponentName] = componentNames;
const Component = require(`../../components/${componentName}`).default as any;
const name = options?.name ? `(${options.name})` : '';
describe(`RootProps${name}`, () => {
@ -36,6 +40,7 @@ export default function rootPropsTest(
beforeEach(() => {
passed = false;
jest.useFakeTimers();
document.body.innerHTML = '';
});
afterEach(() => {
@ -46,7 +51,7 @@ export default function rootPropsTest(
jest.useRealTimers();
});
it('rootClassName', async () => {
it(['rootClassName', subComponentName].filter((v) => v).join(' '), async () => {
const rootClassName = 'TEST_ROOT_CLS';
if (options?.beforeRender) {
@ -104,7 +109,7 @@ export default function rootPropsTest(
expect(childList.length).toBeGreaterThan(0);
if (options?.expectCount) {
expect(childList.length).toBe(options.expectCount);
expect(childList).toHaveLength(options.expectCount);
}
childList.forEach((ele) => {

View File

@ -1,10 +1,10 @@
import type { ReactElement } from 'react';
import React, { createRef, StrictMode } from 'react';
import type { RenderOptions } from '@testing-library/react';
import { act, render } from '@testing-library/react';
import MockDate from 'mockdate';
import { _rs as onEsResize } from 'rc-resize-observer/es/utils/observerUtil';
import { _rs as onLibResize } from 'rc-resize-observer/lib/utils/observerUtil';
import type { ReactElement } from 'react';
import React, { StrictMode } from 'react';
export function assertsExist<T>(item?: T): asserts item is T {
expect(item).not.toBeUndefined();
@ -33,7 +33,7 @@ const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>
render(ui, { wrapper: StrictMode, ...options });
export function renderHook<T>(func: () => T): { result: React.RefObject<T> } {
const result = React.createRef<T>();
const result = createRef<T>();
const Demo: React.FC = () => {
(result as any).current = func();
@ -58,7 +58,7 @@ export { pureRender, customRender as render };
export const triggerResize = (target: Element) => {
const originGetBoundingClientRect = target.getBoundingClientRect;
target.getBoundingClientRect = () => ({ width: 510, height: 903 } as DOMRect);
target.getBoundingClientRect = () => ({ width: 510, height: 903 }) as DOMRect;
act(() => {
onLibResize([{ target } as ResizeObserverEntry]);