From eff720837cb5e5874ed50cbf300d004c4c2dea6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E7=88=B1=E5=90=83=E7=99=BD=E8=90=9D?= =?UTF-8?q?=E5=8D=9C?= Date: Thu, 21 Dec 2023 10:30:08 +0800 Subject: [PATCH] test: Update image snapshot logic (#46529) * test: mix snapshot * test: clean up * test: fix order * test: fix test logic * test: fix reset * test: refactor code * test: clean fetch * chore: lint * test: clean up * test: delay check * chore: refactor fetch * docs: update diff html * test: add ssr support * chore: part use ssr * chore: update style * chore: slice of it * docs: fix cut --- .jest.image.js | 4 +- .jest.js | 2 +- .jest.node.js | 2 +- components/carousel/__tests__/image.test.ts | 4 +- .../date-picker/__tests__/demo.test.tsx | 5 +- components/progress/__tests__/image.test.ts | 4 +- package.json | 2 +- scripts/visual-regression/build.ts | 39 +++---- .../visual-regression/report-template.html | 15 +++ tests/{setup.js => setup.ts} | 46 +++++--- tests/shared/imageTest.tsx | 101 +++++++++++++++++- tests/shared/rootPropsTest.tsx | 13 ++- tests/utils.tsx | 8 +- 13 files changed, 187 insertions(+), 58 deletions(-) rename tests/{setup.js => setup.ts} (51%) diff --git a/.jest.image.js b/.jest.image.js index fb42545326..78191c45d4 100644 --- a/.jest.image.js +++ b/.jest.image.js @@ -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, }; diff --git a/.jest.js b/.jest.js index d71c4e8497..c3a1708cc4 100644 --- a/.jest.js +++ b/.jest.js @@ -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/'], diff --git a/.jest.node.js b/.jest.node.js index 54d6fb852d..e4631d406e 100644 --- a/.jest.node.js +++ b/.jest.node.js @@ -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, diff --git a/components/carousel/__tests__/image.test.ts b/components/carousel/__tests__/image.test.ts index 30d204a801..4d1bdc6da2 100644 --- a/components/carousel/__tests__/image.test.ts +++ b/components/carousel/__tests__/image.test.ts @@ -1,5 +1,7 @@ import { imageDemoTest } from '../../../tests/shared/imageTest'; describe('Carousel image', () => { - imageDemoTest('carousel'); + imageDemoTest('carousel', { + ssr: true, + }); }); diff --git a/components/date-picker/__tests__/demo.test.tsx b/components/date-picker/__tests__/demo.test.tsx index 162c3a5a26..d8c4e237bb 100644 --- a/components/date-picker/__tests__/demo.test.tsx +++ b/components/date-picker/__tests__/demo.test.tsx @@ -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) => , { findRootElements: () => document.querySelectorAll('.ant-picker-range, .ant-picker-dropdown'), diff --git a/components/progress/__tests__/image.test.ts b/components/progress/__tests__/image.test.ts index ba763d18ec..e3298b732e 100644 --- a/components/progress/__tests__/image.test.ts +++ b/components/progress/__tests__/image.test.ts @@ -1,5 +1,7 @@ import { imageDemoTest } from '../../../tests/shared/imageTest'; describe('Progress image', () => { - imageDemoTest('progress'); + imageDemoTest('progress', { + ssr: true, + }); }); diff --git a/package.json b/package.json index aac032334c..b1db1c7a11 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/visual-regression/build.ts b/scripts/visual-regression/build.ts index 27e5842318..8bbdb75b52 100644 --- a/scripts/visual-regression/build.ts +++ b/scripts/visual-regression/build.ts @@ -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'; @@ -158,10 +157,7 @@ function generateReport( const htmlReportLink = `${publicPath}/visualRegressionReport/report.html`; - const addonFullReportDesc = `\n\nToo many visual-regression diffs found, please check Full Report for details`; - - // github action pr comment has limit of 65536 4-byte unicode characters - const limit = 65536 - addonFullReportDesc.length; + const addonFullReportDesc = `\n\nCheck Full Report for details`; let reportMdStr = ` ${commonHeader} @@ -174,7 +170,7 @@ ${commonHeader} let fullVersionMd = reportMdStr; - let addonFullReportDescAdded = false; + let diffCount = 0; for (const badCase of badCases) { const { filename, type } = badCase; @@ -199,17 +195,16 @@ ${commonHeader} lineReportMdStr += ' |\n'; } - if (lineReportMdStr) { - if (reportMdStr.length + lineReportMdStr.length < limit) { - reportMdStr += lineReportMdStr; - } else if (!addonFullReportDescAdded) { - reportMdStr += addonFullReportDesc; - addonFullReportDescAdded = true; - } - fullVersionMd += lineReportMdStr; + diffCount += 1; + if (diffCount <= 10) { + reportMdStr += lineReportMdStr; } + + fullVersionMd += lineReportMdStr; } + reportMdStr += addonFullReportDesc; + // convert fullVersionMd to html return [reportMdStr, md2Html(fullVersionMd)]; } diff --git a/scripts/visual-regression/report-template.html b/scripts/visual-regression/report-template.html index dc5b178f3f..96c3f4aac7 100644 --- a/scripts/visual-regression/report-template.html +++ b/scripts/visual-regression/report-template.html @@ -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; } diff --git a/tests/setup.js b/tests/setup.ts similarity index 51% rename from tests/setup.js rename to tests/setup.ts index e5da82ca53..1977c6b1a8 100644 --- a/tests/setup.js +++ b/tests/setup.ts @@ -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 = { -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 & 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; diff --git a/tests/shared/imageTest.tsx b/tests/shared/imageTest.tsx index 9c104987bd..c6dfd6b136 100644 --- a/tests/shared/imageTest.tsx +++ b/tests/shared/imageTest.tsx @@ -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('

', { + 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 = `
`; + container = doc.querySelector('#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 = ( - + {themedComponent} ); - 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, }); }); }); diff --git a/tests/shared/rootPropsTest.tsx b/tests/shared/rootPropsTest.tsx index 1f4ee413df..b38ce248c4 100644 --- a/tests/shared/rootPropsTest.tsx +++ b/tests/shared/rootPropsTest.tsx @@ -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 & Record, 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) => { diff --git a/tests/utils.tsx b/tests/utils.tsx index d3427d79e3..3355faf802 100644 --- a/tests/utils.tsx +++ b/tests/utils.tsx @@ -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(item?: T): asserts item is T { expect(item).not.toBeUndefined(); @@ -33,7 +33,7 @@ const customRender = (ui: ReactElement, options?: Omit render(ui, { wrapper: StrictMode, ...options }); export function renderHook(func: () => T): { result: React.RefObject } { - const result = React.createRef(); + const result = createRef(); 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]);