Sfoglia il codice sorgente

test: merge cypress and jest enzyme code coverage (#733)

走鹃 3 anni fa
parent
commit
fbce07d47b

+ 2 - 0
.gitignore

@@ -25,6 +25,7 @@ public
 storybook-static/
 *.zip
 cypress/videos/
+cypress/screenshots/
 
 # misc
 .env.local
@@ -92,6 +93,7 @@ lib-cov
 
 # Coverage directory used by tools like istanbul
 coverage
+test/merged
 
 # nyc test coverage
 .nyc_output

+ 50 - 28
.storybook/base/base.js

@@ -1,22 +1,44 @@
 
 const path = require('path');
 const _ = require('lodash');
+const chalk = require('chalk').default;
+
+const utils = require('./utils');
 
 function resolve(...dirs) {
     return path.join(__dirname, '../..', ...dirs);
 }
 
+/**
+ * 当我们想获取 Cypress 代码覆盖率时,需要将 TEST_ENV 设置为 true。
+ * 
+ * 这时会打开 babel-loader 配置,去掉 esbuild 配置,并在 babel plugin 中注入 babel-plugin-istanbul
+ * 
+ * @see https://github.com/istanbuljs/babel-plugin-istanbul
+ */
+function getAddons() {
+    let addons = [
+        '@storybook/addon-a11y',
+        '@storybook/addon-toolbars',
+    ];
+    
+    if (!utils.isTest()) {
+
+        console.log(chalk.yellow(`if you want to get cypress code coverage, set TEST_ENV=test, now it is '${process.env.TEST_ENV}'`));
+
+        addons.unshift({
+            name: "storybook-addon-turbo-build",
+            options: {
+              optimizationLevel: 3,
+            },
+        });
+    }
+
+    return addons;
+}
+
 module.exports = {
-  "addons": [
-    {
-      name: "storybook-addon-turbo-build",
-      options: {
-        optimizationLevel: 3,
-      },
-    },
-    '@storybook/addon-a11y',
-    '@storybook/addon-toolbars',
-  ],
+  addons: getAddons(),
   webpackFinal: async (config) => {
     const rules =
         (config.module.rules &&
@@ -28,24 +50,24 @@ module.exports = {
                 return true;
             })) ||
         [];
-    rules.unshift({
-        test: /\.tsx/,
-        exclude: /node_modules/,
-        loader: 'esbuild-loader',
-        options: {
-            loader: 'tsx',
-            target: 'es2015'
-        }
-    });
-    rules.unshift({
-        test: /\.ts/,
-        exclude: /node_modules/,
-        loader: 'esbuild-loader',
-        options: {
-            loader: 'ts',
-            target: 'es2015'
-        }
-    });
+    // rules.unshift({
+    //     test: /\.tsx/,
+    //     exclude: /node_modules/,
+    //     loader: 'esbuild-loader',
+    //     options: {
+    //         loader: 'tsx',
+    //         target: 'es2015'
+    //     }
+    // });
+    // rules.unshift({
+    //     test: /\.ts/,
+    //     exclude: /node_modules/,
+    //     loader: 'esbuild-loader',
+    //     options: {
+    //         loader: 'ts',
+    //         target: 'es2015'
+    //     }
+    // });
     rules.push(
         {
             test: /\.css$/,

+ 7 - 0
.storybook/base/utils.js

@@ -0,0 +1,7 @@
+function isTest() {
+    return process.env.TEST_ENV === 'test';
+}
+
+module.exports = {
+    isTest
+};

+ 21 - 0
.storybook/js/main.js

@@ -1,4 +1,6 @@
 const config = require('../base/base');
+const utils = require('../base/utils');
+const nycConfig = require('../../nyc.config');
 
 module.exports = {
   ...config,
@@ -9,4 +11,23 @@ module.exports = {
     check: false,
     checkOptions: {}
   },
+  babel: (options) => {
+    const istanbulPluginOption = [
+      'babel-plugin-istanbul',
+      {
+        "include": nycConfig.include,
+        "exclude": nycConfig.exclude
+      }
+    ];
+
+    // 如果是测试环境,则插入 istanbul babel 插件
+    if (utils.isTest()) {
+      options.plugins.unshift(istanbulPluginOption);
+    }
+
+
+    return ({
+      ...options,
+    })
+  },
 };

+ 5 - 0
cypress/fixtures/placeholder.json

@@ -0,0 +1,5 @@
+{
+    "short": "hello semi",
+    "medium": "A modern, comprehensive, flexible design system that gives you all modular blocks you need to build sensible web apps & SaaS products.",
+    "long": "Semi Design is a design system designed, developed and maintained by the Douyin front-end team and the MED product design team. As a comprehensive, easy-to-use, and high-quality modern enterprise-level application UI solution, it is refined from the complex scenes of Bytedance various business lines, supports nearly a thousand platform products, and serves 100,000+ internal and external users. After nearly two years of iteration, Semi Design has become a cross-departmental infrastructure after various types of business landing verification, and has formed a rich tool chain and ecology around the component library. In order to allow the increasingly mature design system to serve more users and to further explore the usage scenarios, we decided to open source Semi and use the power of the community to continuously improve and expand the capability boundary."
+}

+ 35 - 0
cypress/integration/modal.spec.js

@@ -0,0 +1,35 @@
+// modal.spec.js created with Cypress
+//
+// Start writing your Cypress tests below!
+// If you're unfamiliar with how Cypress works,
+// check out the link below and learn how to write your first test:
+// https://on.cypress.io/writing-first-test
+
+describe('modal', () => {
+    it('useModal', () => {
+        cy.visit("http://localhost:6006/iframe.html?id=modal--use-modal-demo&viewMode=story");
+        cy.get(".semi-button").click();
+        cy.get(".semi-modal-confirm-title-text").contains("old title");
+        cy.get(".semi-modal-confirm-content").contains("old content");
+        cy.wait(1000);
+        cy.get(".semi-modal-confirm-title-text").contains("new title");
+        cy.get(".semi-modal-confirm-content").contains("new content");
+        cy.get(".semi-modal-footer .semi-button").last().contains("Confirm");
+        cy.get(".semi-modal-header .semi-modal-close").click();
+        cy.get(".semi-modal").should("not.exist");
+    });
+
+    it('useModal destroy', () => {
+        cy.visit("http://localhost:6006/iframe.html?id=modal--use-modal-destroy&viewMode=story");
+        cy.get(".semi-button").click();
+        cy.wait(1000);
+        cy.get(".semi-modal").should("not.exist");
+    });
+
+    it('useModal afterClose', () => {
+        cy.visit("http://localhost:6006/iframe.html?id=modal--use-modal-after-close&viewMode=story");
+        cy.get(".semi-button").click();
+        cy.get(".semi-modal").should("not.exist");
+        cy.get(".semi-tag").first().contains("true");
+    });
+});

+ 16 - 0
cypress/integration/notification.spec.js

@@ -0,0 +1,16 @@
+// Start writing your Cypress tests below!
+// If you're unfamiliar with how Cypress works,
+// check out the link below and learn how to write your first test:
+// https://on.cypress.io/writing-first-test
+
+describe('notification', () => {
+    it('useNotification', () => {
+        cy.visit("http://localhost:6006/iframe.html?id=notification--use-notification-demo&args=&viewMode=story");
+        cy.get('.semi-button').click();
+        cy.get('[data-cy=notice-container] .semi-notification-notice').should("have.length", 5);
+        // addNotice 返回 id 是固定的,等待代码修改
+        // cy.wait(1000);
+        // cy.get('[data-cy=notice-container] .semi-notification-notice .semi-notification-notice-icon-close').first().click();
+        // cy.get('[data-cy=notice-container] .semi-notification-notice').should("have.length", 4);
+    });
+});

+ 25 - 0
cypress/integration/textarea.spec.js

@@ -0,0 +1,25 @@
+// textarea.spec.js created with Cypress
+//
+// Start writing your Cypress tests below!
+// If you're unfamiliar with how Cypress works,
+// check out the link below and learn how to write your first test:
+// https://on.cypress.io/writing-first-test
+
+describe('textarea', () => {
+    it('autosize', () => {
+        cy.visit('http://localhost:6006/iframe.html?id=input--text-area-autosize&args=&viewMode=story');
+        cy.get('.semi-input-textarea').first().type("semi design");
+        cy.fixture("placeholder").then(placeholder => {
+            cy.get('.semi-input-textarea').first().type(placeholder.medium);
+            cy.document().then(document => {
+                const textAreaDOM = document.querySelector(".semi-input-textarea");
+                expect(textAreaDOM.scrollHeight).to.equal(textAreaDOM.clientHeight);
+            });
+            cy.get('.semi-input-textarea').first().clear().type(placeholder.long);
+            cy.document().then(document => {
+                const textAreaDOM = document.querySelector(".semi-input-textarea");
+                expect(textAreaDOM.scrollHeight).to.equal(textAreaDOM.clientHeight);
+            });
+        });
+    });
+});

+ 17 - 0
cypress/integration/toast.spec.js

@@ -0,0 +1,17 @@
+// Start writing your Cypress tests below!
+// If you're unfamiliar with how Cypress works,
+// check out the link below and learn how to write your first test:
+// https://on.cypress.io/writing-first-test
+
+describe('toast', () => {
+    it('useToast', () => {
+        cy.visit("http://localhost:6006/iframe.html?id=toast--use-toast-demo&args=&viewMode=story");
+        cy.get('.semi-button').click();
+        cy.get('[data-cy=context-holder] .semi-toast').contains("ReachableContext: Light");
+        cy.get('[data-cy=context-holder] .semi-toast').should("have.length", 5);
+        cy.wait(100);
+        cy.get('[data-cy=context-holder] .semi-toast').should("have.length", 4);
+        cy.get('[data-cy=context-holder] .semi-toast .semi-toast-close-button .semi-button').first().click();
+        cy.get('[data-cy=context-holder] .semi-toast').should("have.length", 3);
+    });
+});

+ 5 - 3
cypress/plugins/index.js

@@ -17,6 +17,8 @@
  */
 // eslint-disable-next-line no-unused-vars
 module.exports = (on, config) => {
-  // `on` is used to hook into various events Cypress emits
-  // `config` is the resolved Cypress config
-}
+    // `on` is used to hook into various events Cypress emits
+    // `config` is the resolved Cypress config
+    require('@cypress/code-coverage/task')(on, config);
+    return config;
+};

+ 2 - 1
cypress/support/index.js

@@ -14,7 +14,8 @@
 // ***********************************************************
 
 // Import commands.js using ES2015 syntax:
-import './commands'
+import './commands';
+import '@cypress/code-coverage/support';
 
 // Alternatively you can use CommonJS syntax:
 // require('./commands')

+ 7 - 2
jest.config.js

@@ -44,11 +44,16 @@ let config = {
     // 是否收集测试覆盖率
     //   collectCoverage: true, // 是否收集测试时的覆盖率信息
     collectCoverageFrom: [
-        'packages/semi-ui/*/*.{js,jsx,mjs,ts,tsx}',
-        'packages/semi-foundation/*/*.{js,jsx,mjs,ts,tsx}',
+        'packages/semi-ui/**/*.{js,jsx,mjs,ts,tsx}',
+        'packages/semi-foundation/**/*.{js,jsx,mjs,ts,tsx}',
         '!packages/semi-ui/scripts/**',
         '!packages/semi-ui/types/**',
         '!packages/semi-foundation/scripts/**',
+        '!packages/**/__test__/**',
+        '!packages/**/_story/**',
+        "!packages/**/getBabelConfig.js",
+        "!packages/**/gulpfile.js",
+        "!packages/**/webpack.config.js"
 
     ], // 哪些文件需要收集覆盖率信息
     coverageDirectory: '<rootDir>/test/coverage', // 输出覆盖信息文件的目录

+ 20 - 0
nyc.config.js

@@ -0,0 +1,20 @@
+module.exports = {
+    "report-dir": "cypress/coverage",
+    "reporter": ["text", "json", "lcov"],
+    "all": true,
+    "include": [
+        "packages/semi-ui/**/*.{js,jsx,ts,tsx}",
+        "packages/semi-foundation/**/*.{js,jsx,ts,tsx}"
+    ],
+    "exclude": [
+        "**/*.test.js",
+        "**/*.stories.js",
+        "packages/**/scripts/**",
+        "packages/**/types/**",
+        "packages/**/__test__/**",
+        "packages/**/_story/**",
+        "packages/**/getBabelConfig.js",
+        "packages/**/gulpfile.js",
+        "packages/**/webpack.config.js"
+    ]
+};

+ 7 - 1
package.json

@@ -35,7 +35,11 @@
     "build:css": "lerna run build:css",
     "build-storybook": "build-storybook  -c ./.storybook/js/",
     "build:gatsbydoc": "lerna run build:lib --scope=@douyinfe/semi-webpack-plugin && cross-env NODE_ENV=production node --max_old_space_size=16384 ./node_modules/gatsby/cli.js build --prefix-paths --verbose && rm -rf build && mv public build",
-    "build:icon": "lerna run build:icon --scope='@douyinfe/semi-{icons,illustrations}'"
+    "build:icon": "lerna run build:icon --scope='@douyinfe/semi-{icons,illustrations}'",
+    "cypress:coverage": "npx wait-on http://127.0.0.1:6006 && ./node_modules/.bin/cypress run",
+    "postcypress:coverage": "yarn coverage:merge",
+    "posttest:coverage": "yarn coverage:merge",
+    "coverage:merge": "npx istanbul-combine -d test/merged -p detail -r lcov -r json cypress/coverage/coverage-final.json test/coverage/coverage-final.json"
   },
   "dependencies": {
     "@douyinfe/semi-site-banner": "0.0.1",
@@ -108,6 +112,7 @@
     "@babel/types": "^7.15.4",
     "@commitlint/cli": "^9.1.2",
     "@commitlint/config-conventional": "^7.6.0",
+    "@cypress/code-coverage": "^3.9.12",
     "@octokit/rest": "^18.12.0",
     "@shopify/jest-dom-mocks": "^2.11.7",
     "@storybook/addon-a11y": "^6.4.10",
@@ -138,6 +143,7 @@
     "babel-core": "^7.0.0-bridge.0",
     "babel-jest": "^24.9.0",
     "babel-loader": "^8.2.2",
+    "babel-plugin-istanbul": "^6.1.1",
     "babel-plugin-transform-require-context": "^0.1.1",
     "babel-runtime": "^6.26.0",
     "case-sensitive-paths-webpack-plugin": "^2.4.0",

+ 2 - 6
packages/semi-ui/_base/_story/index.stories.js

@@ -1,19 +1,15 @@
 import React, { useMemo } from 'react';
 import { Button, Typography, Card, Tooltip, Tag, Avatar, Rating, Nav, Layout } from '../../index';
 import { IconHelpCircle, IconUser, IconStar, IconSetting } from '@douyinfe/semi-icons';
-import SemiA11y from './a11y';
 import './index.scss';
 
 export default {
   title: 'Base',
 };
 
-export {
-  TestAlwaysDarkLight,
-  SemiA11y
-};
+export { default as SemiA11y } from './a11y';
 
-const TestAlwaysDarkLight = () => {
+export const TestAlwaysDarkLight = () => {
   function Demo() {
     const { Text } = Typography;
     const { Header, Footer, Sider, Content } = Layout;

+ 1 - 5
packages/semi-ui/_portal/_story/portal.stories.js

@@ -6,11 +6,7 @@ export default {
   title: 'Portal',
 }
 
-export {
-  Basic
-}
-
-const Basic = () => (
+export const Basic = () => (
   <div>
     <Portal>123</Portal>
   </div>

+ 1 - 0
packages/semi-ui/_utils/hooks/usePrevFocus.ts

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
 import { getActiveElement } from '../index';
 import { get, isFunction } from 'lodash';
 
+/* istanbul ignore next */
 export function usePrevFocus() {
     const [prevFocusElement, setPrevFocus] = useState<HTMLElement>(getActiveElement());
 

+ 10 - 1
packages/semi-ui/input/_story/input.stories.js

@@ -897,4 +897,13 @@ export const InputFocus = () => {
       <Input ref={ref} onChange={() => console.log('ref', ref) } onFocus={() => console.log('focus')} />
     </>
   );
-};
+};
+
+export const TextAreaAutosize = () => {
+  return (
+    <div style={{ width: 200 }}>
+      <TextArea autosize />
+    </div>
+  )
+};
+TextAreaAutosize.storyName = "textarea autosize";

+ 93 - 1
packages/semi-ui/modal/_story/modal.stories.js

@@ -1,5 +1,7 @@
 import React, { useState } from 'react';
-import { Select, Modal, Button, Tooltip, Popover } from '../../index';
+import en_GB from '../../locale/source/en_GB';
+
+import { Select, Modal, Button, Tooltip, Popover, ConfigProvider, Tag, Space } from '../../index';
 import CollapsibleInModal from './CollapsibleInModal';
 import DynamicContextDemo from './DynamicContext';
 
@@ -248,4 +250,94 @@ KeepDomNotLazy.story = {
   name: 'keepDOM && not lazy',
 };
 
+export const UseModalDemo = () => {
+  const [modal, contextHolder] = Modal.useModal();
+  const config = { 'title': 'old title', 'content': 'old content' };
+
+  return (
+      <ConfigProvider locale={en_GB}>
+          <div>
+              <Button
+                  onClick={() => {
+                      const currentModal = modal.confirm(config);
+
+                      setTimeout(() => {
+                        currentModal.update({ title: "new title", content: "new content" });
+                      }, 1000);
+                  }}
+              >
+                  Confirm Modal
+              </Button>
+          </div>
+          {contextHolder}
+      </ConfigProvider>
+  );
+};
+UseModalDemo.storyName = "useModal";
+
+export const UseModalDestroy = () => {
+  const [modal, contextHolder] = Modal.useModal();
+  const config = { 'title': 'old title', 'content': 'old content' };
+
+  return (
+      <ConfigProvider locale={en_GB}>
+          <div>
+              <Button
+                  onClick={() => {
+                      const currentModal = modal.confirm(config);
+
+                      setTimeout(() => {
+                        currentModal.destroy();
+                      }, 1000);
+                  }}
+              >
+                  Confirm Modal
+              </Button>
+          </div>
+          {contextHolder}
+      </ConfigProvider>
+  );
+};
+UseModalDestroy.storyName = "useModal destroy";
+
+export const UseModalAfterClose = () => {
+  const [modal, contextHolder] = Modal.useModal();
+  const [closed, setClosed] = React.useState(false);
+  const [leave, setLeave] = React.useState(false);
+
+  const config = { 
+    title: 'old title', 
+    content: 'old content', 
+    afterClose: () => {
+      setClosed(true);
+    },
+    motion: {
+      didLeave: () => {
+        console.log('didLeave');
+        setLeave(true);
+      }
+    }
+  };
 
+  return (
+      <ConfigProvider locale={en_GB}>
+          <Space>
+              <Button
+                  onClick={() => {
+                      const currentModal = modal.confirm(config);
+
+                      setTimeout(() => {
+                        currentModal.destroy();
+                      }, 0);
+                  }}
+              >
+                  Confirm Modal
+              </Button>
+              <Tag>{`closed: ${closed}`}</Tag>
+              {/* <Tag>{`motion leave: ${leave}`}</Tag> */}
+          </Space>
+          {contextHolder}
+      </ConfigProvider>
+  );
+};
+UseModalAfterClose.storyName = "useModal afterClose";

+ 1 - 0
packages/semi-ui/modal/useModal/HookModal.tsx

@@ -35,6 +35,7 @@ const HookModal = ({ afterClose, config, ...props }: PropsWithChildren<HookModal
     }));
 
     const { motion } = props;
+    /* istanbul ignore next */
     const mergedMotion =
         typeof motion === 'undefined' || motion ?
             {

+ 21 - 7
packages/semi-ui/notification/_story/useNotification/index.jsx

@@ -4,18 +4,32 @@ import { Button, ConfigProvider } from '../../../index';
 import Context from './context';
 
 function App({ children, globalVars }) {
-    return <Context.Provider value={{ title: '1111', ...globalVars }}>{children}</Context.Provider>;
+    return (
+        <div data-cy="notice-container">
+            <Context.Provider value={{ title: '1111', ...globalVars }}>{children}</Context.Provider>
+        </div>
+    );
 }
 
 export default function Demo() {
-    const [Notice, elements] = useNotification();
+    const [notice, elements] = useNotification();
+    const config = {
+        content: 'Hello World',
+        position: 'top',
+        title: <Context.Consumer>{({ title }) => <strong>{title}</strong>}</Context.Consumer>,
+        duration: 0,
+    };
 
     const addNotice = () => {
-        Notice.addNotice({
-            content: 'Hello World',
-            position: 'top',
-            title: <Context.Consumer>{({ title }) => <strong>{title}</strong>}</Context.Consumer>,
-        });
+        const id1 = notice.info(config);
+        const id2 = notice.success(config);
+        const id3 = notice.warning(config);
+        const id4 = notice.error(config);
+        const id5 = notice.open(config);
+
+        // setTimeout(() => {
+        //     notice.close(id5);
+        // }, 1000);
     };
 
     return (

+ 41 - 0
packages/semi-ui/toast/_story/toast.stories.js

@@ -96,3 +96,44 @@ export const _Toast = () => (
 _Toast.story = {
   name: 'toast',
 };
+
+const ReachableContext = React.createContext();
+
+/**
+ * test with cypress
+ * @returns 
+ */
+export const useToastDemo = () => {
+  const [toast, contextHolder] = Toast.useToast();
+  const config = {
+      duration: 0,
+      title: 'This is a success message',
+      content: <ReachableContext.Consumer>{name => `ReachableContext: ${name}`}</ReachableContext.Consumer>,
+  };
+
+  return (
+      <ReachableContext.Provider value="Light">
+          <div>
+              <Button
+                onClick={() => {
+                    toast.success(config);
+                    toast.info(config);
+                    toast.error(config);
+                    toast.warning(config);
+                    const id = toast.open(config);
+
+                    setTimeout(() => {
+                      toast.close(id);
+                    }, 100);
+                }}
+              >
+                  Hook Toast
+              </Button>
+          </div>
+          <div data-cy="context-holder">
+            {contextHolder}
+          </div>
+      </ReachableContext.Provider>
+  );
+};
+useToastDemo.storyName = "useToast";

File diff suppressed because it is too large
+ 554 - 2
yarn.lock


Some files were not shown because too many files changed in this diff