├── .circleci └── config.yml ├── .dockerignore ├── .eslintrc.js ├── .gitattributes ├── .github └── stale.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode └── settings.json ├── .yarnclean ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── asset-transform └── index.js ├── brand ├── logo.png ├── logo.sketch ├── social.png └── social.svg ├── css-modules-transform ├── index.js └── postcss-runner.js ├── css-transform └── index.js ├── docs ├── adding-a-browser.md ├── architecture.md ├── assets │ └── facebook-link-screenshot.png ├── index.md ├── jest-hooks.md ├── percy.md ├── storing-snapshots.md ├── supporting-css.md ├── supporting-static-assets.md └── technical-journey.md ├── example-code.png ├── example-pr.png ├── example ├── FancyButton.jsx ├── FancyButton.screenshot.jsx ├── __screenshots__ │ └── FancyButton │ │ ├── Desktop - custom label-snap.png │ │ ├── Desktop - empty label-snap.png │ │ ├── Desktop - with label-snap.png │ │ ├── iPhone X - custom label-snap.png │ │ ├── iPhone X - empty label-snap.png │ │ └── iPhone X - with label-snap.png ├── babel.config.js ├── global.css ├── jest.screenshot.config.js ├── package.json ├── react-screenshot-test.config.js ├── style.module.css └── yarn.lock ├── global-setup.js ├── global-teardown.js ├── index.js ├── jest.config.js ├── jest.screenshot.config.js ├── package.json ├── sass-transform └── index.js ├── src ├── assets.d.ts ├── css-modules.d.ts ├── lib │ ├── browser │ │ └── chrome.ts │ ├── constants.ts │ ├── docker-entrypoint.ts │ ├── global-setup.ts │ ├── global-teardown.ts │ ├── index.ts │ ├── logger.ts │ ├── network │ │ └── fetch.ts │ ├── react │ │ ├── ReactComponentServer.spec.tsx │ │ ├── ReactComponentServer.ts │ │ └── ReactScreenshotTest.ts │ ├── recorded-assets.ts │ ├── recorded-css.ts │ ├── screenshot-renderer │ │ ├── PercyScreenshotRenderer.ts │ │ ├── PuppeteerScreenshotRenderer.spec.ts │ │ ├── PuppeteerScreenshotRenderer.ts │ │ ├── WebdriverScreenshotRenderer.ts │ │ └── api.ts │ └── screenshot-server │ │ ├── DockerizedScreenshotServer.ts │ │ ├── LocalScreenshotServer.spec.ts │ │ ├── LocalScreenshotServer.ts │ │ ├── api.ts │ │ └── config.ts ├── testing │ ├── dummy.ts │ ├── mock.ts │ └── partial-mock.ts └── tests │ ├── __screenshots__ │ ├── Animated components │ │ ├── Desktop - animated-snap.png │ │ └── iPhone X - animated-snap.png │ ├── Images │ │ ├── Desktop - PNG-snap.png │ │ ├── Desktop - SVG-snap.png │ │ ├── Desktop - Static image-snap.png │ │ ├── iPhone X - PNG-snap.png │ │ ├── iPhone X - SVG-snap.png │ │ └── iPhone X - Static image-snap.png │ ├── Remote stylesheet │ │ ├── Desktop - basic div-snap.png │ │ └── iPhone X - basic div-snap.png │ ├── Simple HTML │ │ ├── Desktop - basic div-snap.png │ │ └── iPhone X - basic div-snap.png │ ├── Styled components │ │ ├── Desktop - CSS modules green-snap.png │ │ ├── Desktop - CSS modules red-snap.png │ │ ├── Desktop - SASS green-snap.png │ │ ├── Desktop - emotion CSS-snap.png │ │ ├── Desktop - global CSS blue-snap.png │ │ ├── Desktop - global CSS orange-snap.png │ │ ├── Desktop - inline style CSS-snap.png │ │ ├── Desktop - styled-components CSS-snap.png │ │ ├── iPhone X - CSS modules green-snap.png │ │ ├── iPhone X - CSS modules red-snap.png │ │ ├── iPhone X - SASS green-snap.png │ │ ├── iPhone X - emotion CSS-snap.png │ │ ├── iPhone X - global CSS blue-snap.png │ │ ├── iPhone X - global CSS orange-snap.png │ │ ├── iPhone X - inline style CSS-snap.png │ │ └── iPhone X - styled-components CSS-snap.png │ └── Unicode │ │ ├── Desktop - Chinese-snap.png │ │ ├── Desktop - Emoji-snap.png │ │ ├── Desktop - French-snap.png │ │ ├── Desktop - Japanese-snap.png │ │ ├── iPhone X - Chinese-snap.png │ │ ├── iPhone X - Emoji-snap.png │ │ ├── iPhone X - French-snap.png │ │ └── iPhone X - Japanese-snap.png │ ├── animated.screenshot.tsx │ ├── components │ ├── animated.tsx │ ├── css-modules-green.module.css │ ├── css-modules-green.tsx │ ├── css-modules-red.module.css │ ├── css-modules-red.tsx │ ├── emotion.tsx │ ├── global-css-blue.tsx │ ├── global-css-orange.tsx │ ├── global-style-blue.css │ ├── global-style-orange.css │ ├── inline-style.tsx │ ├── png.tsx │ ├── sass-constants.scss │ ├── sass-green.scss │ ├── sass-green.tsx │ ├── static-image.tsx │ ├── styled-components.tsx │ └── svg.tsx │ ├── global-style.css │ ├── images.screenshot.tsx │ ├── public │ └── react.png │ ├── remote-stylesheet.screenshot.tsx │ ├── simple.screenshot.tsx │ ├── styled.screenshot.tsx │ ├── unicode.screenshot.tsx │ └── viewports.ts ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | anchors: 3 | defaults: &defaults 4 | docker: 5 | - image: fwouts/chrome-screenshot:1.2.2 6 | working_directory: ~/repo 7 | restore_cache: &restore_cache 8 | restore_cache: 9 | keys: 10 | - v1-dependencies-{{ checksum "package.json" }} 11 | # fallback to using the latest cache if no exact match is found 12 | - v1-dependencies- 13 | save_cache: &save_cache 14 | save_cache: 15 | paths: 16 | - node_modules 17 | key: v1-dependencies-{{ checksum "package.json" }} 18 | install_dependencies: &install_dependencies 19 | run: 20 | name: Install dependencies 21 | command: | 22 | apt-get update \ 23 | && apt-get install -y git-lfs 24 | setup_git_lfs: &setup_git_lfs 25 | run: 26 | name: Set up Git LFS 27 | command: | 28 | git lfs install 29 | git lfs pull 30 | jobs: 31 | build: 32 | <<: *defaults 33 | steps: 34 | - checkout 35 | - *restore_cache 36 | - run: yarn install 37 | - *save_cache 38 | - run: yarn build 39 | lint: 40 | <<: *defaults 41 | steps: 42 | - checkout 43 | - *restore_cache 44 | - run: yarn install 45 | - *save_cache 46 | - run: yarn lint:check 47 | unit-test: 48 | <<: *defaults 49 | steps: 50 | - checkout 51 | - *restore_cache 52 | - run: yarn install 53 | - *save_cache 54 | - run: yarn unit-test 55 | screenshot-test-local: 56 | <<: *defaults 57 | steps: 58 | - checkout 59 | - *install_dependencies 60 | - *setup_git_lfs 61 | - *restore_cache 62 | - run: yarn install 63 | - *save_cache 64 | - run: yarn screenshot-test-local 65 | - store_artifacts: 66 | path: src/tests/__screenshots__ 67 | destination: snapshots 68 | screenshot-test-percy: 69 | <<: *defaults 70 | steps: 71 | - checkout 72 | - *restore_cache 73 | - run: yarn install 74 | - *save_cache 75 | - run: yarn screenshot-test-percy 76 | docker-image: 77 | machine: true 78 | steps: 79 | - checkout 80 | - run: docker build . 81 | example-local: 82 | <<: *defaults 83 | steps: 84 | - checkout 85 | - *install_dependencies 86 | - *setup_git_lfs 87 | - run: cd example && yarn install 88 | - run: cd example && yarn screenshot-test-local 89 | - store_artifacts: 90 | path: example/__screenshots__ 91 | destination: example-screenshots 92 | example-percy: 93 | <<: *defaults 94 | steps: 95 | - checkout 96 | - run: cd example && yarn install 97 | - run: cd example && PERCY_TOKEN=$PERCY_TOKEN_EXAMPLE yarn screenshot-test-percy 98 | workflows: 99 | check: 100 | jobs: 101 | - build 102 | - lint 103 | - unit-test 104 | - screenshot-test-local 105 | - screenshot-test-percy 106 | - docker-image 107 | - example-local 108 | - example-percy 109 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | "jest/globals": true 6 | }, 7 | extends: ["plugin:react/recommended", "airbnb", "prettier"], 8 | parser: "@typescript-eslint/parser", 9 | parserOptions: { 10 | ecmaFeatures: { 11 | jsx: true 12 | }, 13 | ecmaVersion: 2018, 14 | sourceType: "module" 15 | }, 16 | plugins: ["react", "jest", "@typescript-eslint"], 17 | settings: { 18 | "import/resolver": { 19 | node: { 20 | extensions: [".js", ".jsx", ".ts", ".tsx"] 21 | } 22 | } 23 | }, 24 | rules: { 25 | "class-methods-use-this": 0, 26 | "import/extensions": [ 27 | "error", 28 | { 29 | js: "never", 30 | jsx: "never", 31 | ts: "never", 32 | tsx: "never" 33 | } 34 | ], 35 | "import/no-extraneous-dependencies": 0, 36 | "import/prefer-default-export": 0, 37 | "no-empty-function": 0, 38 | "no-loop-func": 0, 39 | "no-restricted-syntax": 0, 40 | "no-underscore-dangle": 0, 41 | "no-unused-vars": 0, 42 | "no-use-before-define": 0, 43 | "no-useless-constructor": 0, 44 | "react/button-has-type": 0, 45 | "react/jsx-filename-extension": 0 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | **/__screenshots__/*.* binary 2 | **/__screenshots__/*.* filter=lfs diff=lfs merge=lfs -text 3 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # next.js build output 79 | .next 80 | 81 | # nuxt.js build output 82 | .nuxt 83 | 84 | # gatsby files 85 | .cache/ 86 | 87 | # vuepress build output 88 | .vuepress/dist 89 | 90 | # Serverless directories 91 | .serverless/ 92 | 93 | # FuseBox cache 94 | .fusebox/ 95 | 96 | # DynamoDB Local files 97 | .dynamodb/ 98 | 99 | # TernJS port file 100 | .tern-port 101 | 102 | # Mac temporary files 103 | .DS_Store 104 | 105 | # Compiled directory 106 | dist/ 107 | 108 | # Diff outputs 109 | __diff_output__ 110 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/.npmignore -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/.prettierrc -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.yarnclean: -------------------------------------------------------------------------------- 1 | @types/react-native 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `react-screenshot-test` 2 | 3 | First of all, thank you for helping out! 4 | 5 | ## Understanding how it works 6 | 7 | Please refer to the [internal documentation](./docs/index.md) to understand how React Screenshot Test works. 8 | 9 | ## Code contributions 10 | 11 | Here is a quick guide to contribute to the library. 12 | 13 | 1. Fork and clone the repo to your local machine. 14 | 15 | 2. Create a new branch from `master` with a meaningful name for a new feature or an issue you want to work on: `git checkout -b your-branch-name` 16 | 17 | 3. Install packages by running: 18 | 19 | ```sh 20 | yarn install 21 | ``` 22 | 23 | 4. If you've added a code that should be tested, ensure the test suite still passes and update snapshots. 24 | 25 | ```sh 26 | # Run all unit tests. 27 | yarn unit-test -u 28 | 29 | # Run screenshot tests locally. 30 | yarn screenshot-test-local -u 31 | ``` 32 | 33 | 5. Please make sure your code is well-tested. Feel free to add new tests and sample code. 34 | 35 | 6. Ensure your code lints without errors. 36 | 37 | ```sh 38 | yarn lint 39 | ``` 40 | 41 | 7. Ensure build passes. 42 | 43 | ```sh 44 | yarn build 45 | ``` 46 | 47 | 8. Push your branch: `git push -u origin your-branch-name` 48 | 49 | 9. Submit a pull request to the upstream react-screenshot-test repository. 50 | 51 | 10. Choose a descriptive title and describe your changes briefly. 52 | 53 | ## Coding style 54 | 55 | Please follow the coding style of the project. React Screenshot Test uses eslint and prettier. If possible, enable their respective plugins in your editor to get real-time feedback. 56 | 57 | Linting can be run manually with the following command: `yarn lint` 58 | 59 | ## License 60 | 61 | By contributing your code to the react-screenshot-test GitHub repository, you agree to license your contribution under the MIT license. 62 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM buildkite/puppeteer:8.0.0 2 | RUN rm -rf /node_modules /package.json /package-lock.json 3 | RUN apt-get -qqy update && \ 4 | apt-get -qqy --no-install-recommends install \ 5 | fonts-roboto \ 6 | fonts-noto-cjk \ 7 | fonts-ipafont-gothic \ 8 | fonts-wqy-zenhei \ 9 | fonts-kacst \ 10 | fonts-freefont-ttf \ 11 | fonts-thai-tlwg \ 12 | fonts-indic && \ 13 | apt-get -qyy clean 14 | WORKDIR /renderer 15 | COPY package.json yarn.lock .yarnclean ./ 16 | RUN yarn install 17 | COPY tsconfig.json . 18 | COPY src src 19 | RUN yarn build 20 | ENTRYPOINT [ "node", "dist/lib/docker-entrypoint.js" ] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 François Wouts 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **Warning** 2 | > 3 | > This package is no longer actively maintained. 4 | > 5 | > Check out [@previewjs/screenshot](https://github.com/fwouts/previewjs/tree/main/screenshot) for an alternative. 6 | 7 | # React Screenshot Test 8 | 9 | [![Logo](brand/logo.png)](https://www.npmjs.com/package/react-screenshot-test) 10 | 11 |

12 | 13 | CircleCI 14 | 15 | 16 | npm 17 | 18 | 19 | downloads 20 | 21 | 22 | license 23 | 24 |

25 | 26 |
27 |
28 | 29 | Here is a screenshot test written with `react-screenshot-test`: 30 | 31 | [![Code example](example-code.png)](https://github.com/fwouts/react-screenshot-test/tree/master/example/FancyButton.screenshot.jsx) 32 | 33 | All you need is to install `react-screenshot-test` and configure Jest: 34 | 35 | ```js 36 | // jest.screenshot.config.js 37 | 38 | module.exports = { 39 | testEnvironment: "node", 40 | globalSetup: "react-screenshot-test/global-setup", 41 | globalTeardown: "react-screenshot-test/global-teardown", 42 | testMatch: ["**/?(*.)+(screenshot).[jt]s?(x)"], 43 | transform: { 44 | "^.+\\.[t|j]sx?$": "babel-jest", // or ts-jest 45 | "^.+\\.module\\.css$": "react-screenshot-test/css-modules-transform", 46 | "^.+\\.css$": "react-screenshot-test/css-transform", 47 | "^.+\\.scss$": "react-screenshot-test/sass-transform", 48 | "^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": 49 | "react-screenshot-test/asset-transform" 50 | }, 51 | transformIgnorePatterns: ["node_modules/.+\\.js"] 52 | }; 53 | ``` 54 | 55 | You can then generate screenshots with `jest -c jest.screenshot.config.js -u`, 56 | just like you would with Jest snapshots. 57 | 58 | ## What does it look like? 59 | 60 | Here's a [real example](https://github.com/fwouts/react-screenshot-test/pull/18/files?short_path=9fa0253#diff-9fa0253d6c3a2b1cf8ec498eec18360e) of a pull request where a component was changed: 61 | [![Example PR](example-pr.png)](https://github.com/fwouts/react-screenshot-test/pull/18/files?short_path=c1101dd#diff-c1101ddb11729f8ee0750df5e9595b47) 62 | 63 | ## How does it work? 64 | 65 | Under the hood, we start a local server which renders components server-side. Each component is given its own dedicated page (e.g. /render/my-component). Then we use Puppeteer to take a screenshot of that page. 66 | 67 | Curious to learn more? Check out the [internal documentation](./docs/index.md)! 68 | 69 | ## Cross-platform consistency 70 | 71 | If you work on a team where developers use a different OS (e.g. Mac OS and 72 | Linux), or if you develop on Mac OS but use Linux for continuous integration, 73 | you would quickly run into issues where screenshots are inconsistent across 74 | platforms. This is, for better or worse, expected behaviour. 75 | 76 | In order to work around this issue, `react-screenshot-test` will default to 77 | running Puppeteer (i.e. Chrome) inside Docker to take screenshots of your 78 | components. This ensures that generated screenshots are consistent regardless of 79 | which platform you run your tests on. 80 | 81 | You can override this behaviour by setting the `SCREENSHOT_MODE` environment 82 | variable to `local`, which will always use a local browser instead of Docker. 83 | 84 | _Note: On Linux, `react-screenshot-test` will run Docker using host network mode on port 3001_ 85 | 86 | ## CSS support 87 | 88 | CSS-in-JS libraries such as Emotion and Styled Components are supported. 89 | 90 | | CSS technique | Supported | 91 | | ------------------------------------------------------ | --------- | 92 | | `
`import "./style.css"` | ✅ | 94 | | Sass stylesheets
`import "./style.scss"` | ✅ | 95 | | CSS Modules
`import css from "./style.css"` | ✅ | 96 | | [Emotion](https://emotion.sh) | ✅ | 97 | | [Styled Components](https://www.styled-components.com) | ✅ | 98 | 99 | ## Usage with create-react-app 100 | 101 | If you'd like to set up `react-screenshot-test` with a `create-react-app`, [here is everything you need](https://github.com/fwouts/react-screenshot-test-with-create-react-app/compare/original...master). 102 | 103 | ## Storing image snapshots 104 | 105 | We recommend using [Git LFS](https://git-lfs.github.com) to store image 106 | snapshots. This will help prevent your Git repository from becoming bloated over time. 107 | 108 | If you're unfamiliar with Git LFS, you can learn about it with [this short video (2 min)](https://www.youtube.com/watch?v=uLR1RNqJ1Mw) and/or going through [the official tutorial](https://github.com/git-lfs/git-lfs/wiki/Tutorial). 109 | 110 | To set up Git LFS, [install the Git extension](https://git-lfs.github.com/) and add the following to `.gitattributes` in your repository ([source](https://github.com/americanexpress/jest-image-snapshot/issues/92#issuecomment-493582776)): 111 | 112 | ``` 113 | **/__screenshots__/*.* binary 114 | **/__screenshots__/*.* filter=lfs diff=lfs merge=lfs -text 115 | ``` 116 | 117 | You may also need to set up Git LFS for continuous integration. See [our config](https://github.com/fwouts/react-screenshot-test/blob/master/.circleci/config.yml) for an example with CircleCI. 118 | 119 | ## Usage with Percy 120 | 121 | If you prefer to keep image snapshots out of your repository, you can use a third-party service such as [Percy](https://percy.io): 122 | 123 | - Install `@percy/puppeteer` 124 | - Ensure that `PERCY_TOKEN` is set in your enviroment 125 | - Set up a script to invoke Jest through Percy: 126 | 127 | ```json 128 | { 129 | "screenshot-test-percy": "SCREENSHOT_MODE=percy percy exec -- jest -c jest.screenshot.config.js" 130 | } 131 | ``` 132 | 133 | ## TypeScript support 134 | 135 | This library is written in TypeScript. All declarations are included. 136 | 137 | ## Browser support 138 | 139 | At the moment, screenshots are only generated with Chrome. However, the design can be extended to any headless browser. File an issue if you'd like to help make this happen. 140 | 141 | ## Comparison 142 | 143 | | Tool | Visual | Open Source | Price for 100,000 snapshots/month | Jest integration | Review process | 144 | | ---------------------------------------------------------------------------- | ------ | ----------- | --------------------------------- | ---------------- | ---------------------------------------------------------------------- | 145 | | [react-screenshot-test](https://www.npmjs.com/package/react-screenshot-test) | ✅ | ✅ | Free | ✅ | Pull request | 146 | | [Jest snapshots](https://jestjs.io/docs/en/snapshot-testing) | ❌ | ✅ | Free | ✅ | Pull request | 147 | | [Percy](https://percy.io) | ✅ | ❌ | [\$469](https://percy.io/pricing) | ❌ | Separate UI | | 148 | | [storycap](https://github.com/reg-viz/storycap) | ✅ | ✅ | Free | ❌ | Implicit approval with [reg-suit](https://github.com/reg-viz/reg-suit) | 149 | 150 | ## Reporting issues 151 | 152 | If something doesn't work, or if the documentation is unclear, please do not hesitate to [raise an issue](https://github.com/fwouts/react-screenshot-test/issues)! 153 | 154 | If it doesn't work for you, it probably doesn't work for others either 🙃 155 | -------------------------------------------------------------------------------- /asset-transform/index.js: -------------------------------------------------------------------------------- 1 | // Note: this was forked from 2 | // https://github.com/dferber90/jest-transform-css/blob/master/index.js 3 | 4 | // Note: you must increment this version whenever you update this script or 5 | // anything that it uses. 6 | const TRANSFORM_VERSION = "1"; 7 | 8 | const crypto = require("crypto"); 9 | 10 | module.exports = { 11 | getCacheKey: (fileData, filename, configString, { instrument }) => { 12 | return crypto 13 | .createHash("md5") 14 | .update(TRANSFORM_VERSION) 15 | .update("\0", "utf8") 16 | .update(fileData) 17 | .update("\0", "utf8") 18 | .update(filename) 19 | .update("\0", "utf8") 20 | .update(configString) 21 | .update("\0", "utf8") 22 | .update(instrument ? "instrument" : "") 23 | .digest("hex"); 24 | }, 25 | 26 | process: (src, filename) => { 27 | return ` 28 | const { recordAsset } = require("react-screenshot-test"); 29 | module.exports = recordAsset(${JSON.stringify(filename)}); 30 | `; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /brand/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/brand/logo.png -------------------------------------------------------------------------------- /brand/logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/brand/logo.sketch -------------------------------------------------------------------------------- /brand/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/brand/social.png -------------------------------------------------------------------------------- /brand/social.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | social 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 35 | 36 | react-screenshot-test 37 | 38 | 39 | -------------------------------------------------------------------------------- /css-modules-transform/index.js: -------------------------------------------------------------------------------- 1 | // Note: this was forked from 2 | // https://github.com/dferber90/jest-transform-css/blob/master/index.js 3 | 4 | // Note: you must increment this version whenever you update this script or 5 | // anything that it uses. 6 | const TRANSFORM_VERSION = "1"; 7 | 8 | const crypto = require("crypto"); 9 | const crossSpawn = require("cross-spawn"); 10 | 11 | module.exports = { 12 | getCacheKey: (fileData, filename, configString, { instrument }) => { 13 | return ( 14 | crypto 15 | .createHash("md5") 16 | .update(TRANSFORM_VERSION) 17 | .update("\0", "utf8") 18 | .update(fileData) 19 | .update("\0", "utf8") 20 | .update(filename) 21 | .update("\0", "utf8") 22 | .update(configString) 23 | // TODO load postcssrc (the config) sync and make it part of the cache 24 | // key 25 | // .update("\0", "utf8") 26 | // .update(getPostCssConfig(filename)) 27 | .update("\0", "utf8") 28 | .update(instrument ? "instrument" : "") 29 | .digest("hex") 30 | ); 31 | }, 32 | 33 | process: (src, filename) => { 34 | // The "process" function of this Jest transform must be sync, 35 | // but postcss is async. So we spawn a sync process to do an sync 36 | // transformation! 37 | // https://twitter.com/kentcdodds/status/1043194634338324480 38 | const postcssRunner = `${__dirname}/postcss-runner.js`; 39 | const result = crossSpawn.sync("node", [ 40 | "-e", 41 | ` 42 | require("${postcssRunner}")( 43 | ${JSON.stringify({ 44 | src, 45 | filename 46 | // config, 47 | // options 48 | })} 49 | ) 50 | .then(out => { console.log(JSON.stringify(out)) }) 51 | ` 52 | ]); 53 | 54 | // check for errors of postcss-runner.js 55 | const error = result.stderr.toString(); 56 | if (error) { 57 | throw error; 58 | } 59 | 60 | // read results of postcss-runner.js from stdout 61 | let css; 62 | let tokens; 63 | try { 64 | // we likely logged something to the console from postcss-runner 65 | // in order to debug, and hence the parsing fails! 66 | const parsed = JSON.parse(result.stdout.toString()); 67 | css = parsed.css; 68 | tokens = parsed.tokens; 69 | if (Array.isArray(parsed.warnings)) 70 | parsed.warnings.forEach(warning => { 71 | console.warn(warning); 72 | }); 73 | } catch (e) { 74 | // we forward the logs and return no mappings 75 | console.error(result.stderr.toString()); 76 | console.log(result.stdout.toString()); 77 | return ` 78 | console.error("transform-css: Failed to load '${filename}'"); 79 | module.exports = {}; 80 | `; 81 | } 82 | 83 | // Finally, inject the styles to the document 84 | return ` 85 | const { recordCss } = require("react-screenshot-test"); 86 | recordCss(${JSON.stringify(css)}); 87 | module.exports = ${JSON.stringify(tokens)}; 88 | `; 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /css-modules-transform/postcss-runner.js: -------------------------------------------------------------------------------- 1 | // Note: this was forked from 2 | // https://github.com/dferber90/jest-transform-css/blob/master/postcss-runner.js 3 | 4 | const postcss = require("postcss"); 5 | const postcssrc = require("postcss-load-config"); 6 | const cssModules = require("postcss-modules"); 7 | 8 | // This script is essentially a PostCSS Runner 9 | // https://github.com/postcss/postcss/blob/master/docs/guidelines/runner.md#postcss-runner-guidelines 10 | module.exports = ({ src, filename }) => { 11 | const ctx = { 12 | // Not sure whether the map is useful or not. 13 | // Disabled for now. We can always enable it once it becomes clear. 14 | map: false, 15 | // To ensure that PostCSS generates source maps and displays better syntax 16 | // errors, runners must specify the from and to options. If your runner does 17 | // not handle writing to disk (for example, a gulp transform), you should 18 | // set both options to point to the same file" 19 | // https://github.com/postcss/postcss/blob/master/docs/guidelines/runner.md#21-set-from-and-to-processing-options 20 | from: filename, 21 | to: filename 22 | }; 23 | let tokens = {}; 24 | return postcssrc(ctx) 25 | .then( 26 | config => ({ ...config, plugins: config.plugins || [] }), 27 | error => { 28 | // Support running without postcss.config.js 29 | // This is useful in case the webpack setup of the consumer does not 30 | // use PostCSS at all and simply uses css-loader in modules mode. 31 | if (error.message.startsWith("No PostCSS Config found in:")) { 32 | return { plugins: [], options: { from: filename, to: filename } }; 33 | } 34 | throw error; 35 | } 36 | ) 37 | .then(({ plugins, options }) => { 38 | return postcss([ 39 | cssModules({ 40 | // Should we read generateScopedName from options? 41 | // Does anybody care about the actual names? This is test-only anyways? 42 | // Should be easy to add in case anybody needs it, just pass it through 43 | // from jest.config.js (we have "config" & "options" in css.js) 44 | generateScopedName: "[path][local]-[hash:base64:10]", 45 | getJSON: (cssFileName, exportedTokens, outputFileName) => { 46 | tokens = exportedTokens; 47 | } 48 | }), 49 | ...plugins 50 | ]) 51 | .process(src, options) 52 | .then( 53 | result => ({ 54 | css: result.css, 55 | tokens, 56 | // Display result.warnings() 57 | // PostCSS runners must output warnings from result.warnings() 58 | // https://github.com/postcss/postcss/blob/master/docs/guidelines/runner.md#32-display-resultwarnings 59 | warnings: result.warnings().map(warn => warn.toString()) 60 | }), 61 | // Don’t show JS stack for CssSyntaxError 62 | // PostCSS runners must not show a stack trace for CSS syntax errors, 63 | // as the runner can be used by developers who are not familiar with 64 | // JavaScript. Instead, handle such errors gracefully: 65 | // https://github.com/postcss/postcss/blob/master/docs/guidelines/runner.md#31-dont-show-js-stack-for-csssyntaxerror 66 | error => { 67 | if (error.name === "CssSyntaxError") { 68 | process.stderr.write(error.message + error.showSourceCode()); 69 | } else { 70 | throw error; 71 | } 72 | } 73 | ); 74 | }); 75 | }; 76 | -------------------------------------------------------------------------------- /css-transform/index.js: -------------------------------------------------------------------------------- 1 | // Note: this was forked from 2 | // https://github.com/dferber90/jest-transform-css/blob/master/index.js 3 | 4 | // Note: you must increment this version whenever you update this script or 5 | // anything that it uses. 6 | const TRANSFORM_VERSION = "1"; 7 | 8 | const crypto = require("crypto"); 9 | 10 | module.exports = { 11 | getCacheKey: (fileData, filename, configString, { instrument }) => { 12 | return crypto 13 | .createHash("md5") 14 | .update(TRANSFORM_VERSION) 15 | .update("\0", "utf8") 16 | .update(fileData) 17 | .update("\0", "utf8") 18 | .update(filename) 19 | .update("\0", "utf8") 20 | .update(configString) 21 | .update("\0", "utf8") 22 | .update(instrument ? "instrument" : "") 23 | .digest("hex"); 24 | }, 25 | 26 | process: (src, filename) => { 27 | return ` 28 | const { recordCss } = require("react-screenshot-test"); 29 | recordCss(${JSON.stringify(src)}); 30 | module.exports = {}; 31 | `; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /docs/adding-a-browser.md: -------------------------------------------------------------------------------- 1 | # Adding a browser 2 | 3 | TODO: Write me :) 4 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | There are multiple layers to the library: 4 | 5 | - **[ReactScreenshotTest](https://github.com/fwouts/react-screenshot-test/blob/master/src/lib/react/ReactScreenshotTest.ts) is the library's main entrypoint.** 6 | - It exposes a simple API. 7 | - Internally, it coordinates Jest, the component server and the screenshot renderer. 8 | - **[ReactComponentServer](https://github.com/fwouts/react-screenshot-test/blob/master/src/lib/react/ReactComponentServer.ts) is an HTTP server that renders React components server-side.** 9 | - **[ScreenshotRenderer](https://github.com/fwouts/react-screenshot-test/blob/master/src/lib/screenshot-renderer/api.ts) is an interface wrapping a browser.** 10 | - There are multiple implementations, in particular [**PuppeteerScreenshotRenderer**](https://github.com/fwouts/react-screenshot-test/blob/master/src/lib/screenshot-renderer/PuppeteerScreenshotRenderer.ts) (using Puppeteer) and [**PercyScreenshotRenderer**](https://github.com/fwouts/react-screenshot-test/blob/master/src/lib/screenshot-renderer/PercyScreenshotRenderer.ts) (loading screenshots over HTTP). 11 | - **[ScreenshotServer](https://github.com/fwouts/react-screenshot-test/blob/master/src/lib/screenshot-server/api.ts) is an HTTP server that takes a screenshot of a particular URL.** 12 | - A screenshot server can either be local or it can run within Docker. 13 | - Running it in Docker allows us to take consistent snapshots across platforms. 14 | -------------------------------------------------------------------------------- /docs/assets/facebook-link-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/docs/assets/facebook-link-screenshot.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Internal Documentation 2 | 3 | How does React Screenshot Test work? Good question. 4 | 5 | Read on: 6 | 7 | - [The technical journey](./technical-journey.md) 8 | - [Architecture overview](./architecture.md) 9 | - [Supporting static assets (e.g. images)](./supporting-static-assets.md) 10 | - [Supporting CSS](./supporting-css.md) 11 | - [Jest hooks (coming soon)](./jest-hooks.md) 12 | - [Storing snapshots (coming soon)](./storing-snapshots.md) 13 | - [Integration with Percy (coming soon)](./percy.md) 14 | - [Adding a browser (coming soon)](./adding-a-browser.md) 15 | -------------------------------------------------------------------------------- /docs/jest-hooks.md: -------------------------------------------------------------------------------- 1 | # Jest hooks 2 | 3 | TODO: Write me :) 4 | -------------------------------------------------------------------------------- /docs/percy.md: -------------------------------------------------------------------------------- 1 | # Jest hooks 2 | 3 | TODO: Write me :) 4 | -------------------------------------------------------------------------------- /docs/storing-snapshots.md: -------------------------------------------------------------------------------- 1 | # Storing snapshots 2 | 3 | TODO: Write me :) 4 | -------------------------------------------------------------------------------- /docs/supporting-css.md: -------------------------------------------------------------------------------- 1 | # Supporting CSS 2 | 3 | We've talked about [supporting static assets](./supporting-static-assets.md) such as images. But what about CSS? 4 | 5 | ## Styling components in React 6 | 7 | There are [a lot of ways](https://github.com/MicheleBertoli/css-in-js) to do CSS in React. Here is a quick refresher of the most common approaches, which are all supported by React Screenshot Test. 8 | 9 | ### CSS imports 10 | 11 | This is what I like to call "the old way": import a CSS stylesheet and add class names to components. 12 | 13 | ```tsx 14 | import React from "react"; 15 | import "./style.css"; 16 | 17 | export const UserProfile = () => ( 18 |
19 |

User

20 |
21 | ); 22 | ``` 23 | 24 | ```css 25 | /* style.css */ 26 | .user-profile { 27 | background: black; 28 | } 29 | 30 | .user-profile h1 { 31 | font-size: 14px; 32 | } 33 | ``` 34 | 35 | ### CSS Modules 36 | 37 | [CSS Modules](https://create-react-app.dev/docs/adding-a-css-modules-stylesheet/) allow class names to be automatically generated to avoid conflicts between stylesheets. 38 | 39 | ```tsx 40 | import React from "react"; 41 | import styles from "./style.module.css"; 42 | 43 | // className may end up being "UserProfile_container_ax7yz" 44 | export const UserProfile = () => ( 45 |
46 |

User

47 |
48 | ); 49 | ``` 50 | 51 | ```scss 52 | /* style.module.css */ 53 | .container { 54 | background: black; 55 | } 56 | 57 | .container h1 { 58 | font-size: 14px; 59 | } 60 | ``` 61 | 62 | ### Sass stylesheets 63 | 64 | [Sass](https://sass-lang.com/guide) is an extension to CSS which lets you define variables, nested rules, etc. 65 | 66 | ```tsx 67 | import React from "react"; 68 | import "./style.scss"; 69 | 70 | export const UserProfile = () => ( 71 |
72 |

User

73 |
74 | ); 75 | ``` 76 | 77 | ```scss 78 | /* style.scss */ 79 | @import "./constants.scss"; 80 | 81 | .user-profile { 82 | background: $backgroundColor; 83 | 84 | h1 { 85 | font-size: 14px; 86 | } 87 | } 88 | ``` 89 | 90 | ### CSS-in-JS 91 | 92 | You can also disregard CSS stylesheets entirely and use inline styles: 93 | 94 | ```tsx 95 | import React from "react"; 96 | 97 | export const UserProfile = () => ( 98 |
99 |

User

100 |
101 | ); 102 | ``` 103 | 104 | ### Styled Components 105 | 106 | A more scalable approach to CSS-in-JS is to use a library. [Styled Components](https://www.styled-components.com/) is one of them: 107 | 108 | ```tsx 109 | import React from "react"; 110 | import styled from "styled-components"; 111 | 112 | const Container = styled.div` 113 | background: black; 114 | `; 115 | 116 | const Header = styled.h1` 117 | font-size: 14px; 118 | `; 119 | 120 | export const UserProfile = () => ( 121 | 122 |
User
123 |
124 | ); 125 | ``` 126 | 127 | ### Emotion 128 | 129 | [Emotion](https://emotion.sh/) is a competing library. If you use `@emotion/styled`, it looks exactly the same: 130 | 131 | ```tsx 132 | import React from "react"; 133 | import styled from "@emotion/styled"; 134 | 135 | const Container = styled.div` 136 | background: black; 137 | `; 138 | 139 | const Header = styled.h1` 140 | font-size: 14px; 141 | `; 142 | 143 | export const UserProfile = () => ( 144 | 145 |
User
146 |
147 | ); 148 | ``` 149 | 150 | ## Supporting each method 151 | 152 | Let's go through each method and explain how it works with React Screenshot Test. 153 | 154 | ### CSS imports 155 | 156 | Every time we encounter an import of a `.css` file, we record its content. Later, when the component is rendered by the component server, we inline the CSS into the generated HTML. 157 | 158 | ```js 159 | module.exports = { 160 | // ... 161 | transform: { 162 | "^.+\\.css$": "react-screenshot-test/css-transform" 163 | } 164 | }; 165 | ``` 166 | 167 | ```js 168 | // css-transform/index.js 169 | module.exports = { 170 | process: (src, filename) => { 171 | return ` 172 | const { recordCss } = require("react-screenshot-test"); 173 | recordCss(${JSON.stringify(src)}); 174 | module.exports = {}; 175 | `; 176 | } 177 | }; 178 | ``` 179 | 180 | What does [`recordCSS()`](https://github.com/fwouts/react-screenshot-test/blob/master/src/lib/recorded-css.ts) do? 181 | 182 | - It stores the CSS in memory. 183 | - It leverages [ReactComponentServer](https://github.com/fwouts/react-screenshot-test/blob/master/src/lib/react/ReactComponentServer.ts) to include the CSS into a `
Hello, World!
" 35 | `); 36 | rendered = true; 37 | } 38 | ); 39 | expect(rendered).toBe(true); 40 | await server.stop(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/lib/react/ReactComponentServer.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from "express"; 2 | import getPort from "get-port"; 3 | import { Server } from "net"; 4 | import React from "react"; 5 | import ReactDOMServer from "react-dom/server"; 6 | import * as uuid from "uuid"; 7 | import { debugLogger } from "../logger"; 8 | import { ASSET_SERVING_PREFIX, getAssetFilename } from "../recorded-assets"; 9 | import { readRecordedCss } from "../recorded-css"; 10 | 11 | // Import ServerStyleSheet without importing styled-components, so that 12 | // projects which don't use styled-components don't crash. 13 | type ServerStyleSheet = import("styled-components").ServerStyleSheet; 14 | 15 | const viewportMeta = React.createElement("meta", { 16 | name: "viewport", 17 | content: "width=device-width, initial-scale=1.0", 18 | }); 19 | const charsetMeta = React.createElement("meta", { 20 | name: "charset", 21 | content: "UTF-8", 22 | }); 23 | 24 | const logDebug = debugLogger("ReactComponentServer"); 25 | 26 | /** 27 | * ReactComponentServer renders React nodes in a plain HTML page. 28 | */ 29 | export class ReactComponentServer { 30 | private readonly app: Express; 31 | 32 | private server: Server | null = null; 33 | 34 | private port: number | null = null; 35 | 36 | private readonly nodes: { 37 | [id: string]: NodeDescription; 38 | } = {}; 39 | 40 | constructor(staticPaths: Record) { 41 | this.app = express(); 42 | for (const [mappedPath, dirOrFilePath] of Object.entries(staticPaths)) { 43 | this.app.use(mappedPath, express.static(dirOrFilePath)); 44 | } 45 | this.app.get("/render/:nodeId", (req, res) => { 46 | const { nodeId } = req.params; 47 | const node = this.nodes[nodeId]; 48 | logDebug(`Received request to render node ${nodeId}.`); 49 | if (!node) { 50 | throw new Error(`No node to render for ID: ${nodeId}`); 51 | } 52 | 53 | // In order to render styled components, we need to collect the styles. 54 | // However, some projects don't use styled components, and it woudln't be 55 | // fair to ask them to install it. Therefore, we rely on a dynamic import 56 | // which we expect to fail if the package isn't installed. That's OK, 57 | // because that means we can render without it. 58 | import("styled-components") 59 | .then(({ ServerStyleSheet }) => 60 | this.renderWithStyledComponents(new ServerStyleSheet(), node) 61 | ) 62 | .catch(() => this.renderWithoutStyledComponents(node)) 63 | .then((html) => { 64 | logDebug(`Finished render successfully.`); 65 | res.header("Content-Type", "text/html; charset=utf-8"); 66 | res.send(html); 67 | logDebug(`Rendered HTML sent.`); 68 | }) 69 | .catch(console.error); 70 | }); 71 | this.app.get(`${ASSET_SERVING_PREFIX}:asset.:ext`, (req, res) => { 72 | const filePath = getAssetFilename(req.path); 73 | logDebug(`Serving static asset ${req.path} from ${filePath}.`); 74 | res.sendFile(filePath); 75 | }); 76 | } 77 | 78 | private renderWithStyledComponents( 79 | sheet: ServerStyleSheet, 80 | node: NodeDescription 81 | ) { 82 | logDebug(`Initiating render with styled-components.`); 83 | 84 | // See https://www.styled-components.com/docs/advanced#server-side-rendering 85 | // for details. 86 | try { 87 | const rendered = ReactDOMServer.renderToString( 88 | sheet.collectStyles(node.reactNode) 89 | ); 90 | const html = ReactDOMServer.renderToString( 91 | React.createElement( 92 | "html", 93 | null, 94 | React.createElement( 95 | "head", 96 | null, 97 | charsetMeta, 98 | viewportMeta, 99 | ...node.remoteStylesheetUrls.map((url) => 100 | React.createElement("link", { 101 | rel: "stylesheet", 102 | href: url, 103 | }) 104 | ), 105 | React.createElement("style", { 106 | dangerouslySetInnerHTML: { __html: readRecordedCss() }, 107 | }), 108 | sheet.getStyleElement() 109 | ), 110 | React.createElement("body", { 111 | dangerouslySetInnerHTML: { __html: rendered }, 112 | }) 113 | ) 114 | ); 115 | return html; 116 | } finally { 117 | sheet.seal(); 118 | } 119 | } 120 | 121 | private renderWithoutStyledComponents(node: NodeDescription) { 122 | logDebug(`Initiating render without styled-components.`); 123 | 124 | // Simply render the node. This works with Emotion, too! 125 | return ReactDOMServer.renderToString( 126 | React.createElement( 127 | "html", 128 | null, 129 | React.createElement( 130 | "head", 131 | null, 132 | charsetMeta, 133 | viewportMeta, 134 | ...node.remoteStylesheetUrls.map((url) => 135 | React.createElement("link", { 136 | rel: "stylesheet", 137 | href: url, 138 | }) 139 | ), 140 | React.createElement("style", { 141 | dangerouslySetInnerHTML: { __html: readRecordedCss() }, 142 | }) 143 | ), 144 | React.createElement("body", null, node.reactNode) 145 | ) 146 | ); 147 | } 148 | 149 | async start(): Promise { 150 | logDebug(`start() initiated.`); 151 | 152 | if (this.server) { 153 | throw new Error( 154 | "Server is already running! Please only call start() once." 155 | ); 156 | } 157 | this.port = await getPort(); 158 | 159 | logDebug(`Attempting to listen on port ${this.port}.`); 160 | await new Promise((resolve) => { 161 | this.server = this.app.listen(this.port, resolve); 162 | }); 163 | logDebug(`Successfully listening on port ${this.port}.`); 164 | } 165 | 166 | async stop(): Promise { 167 | logDebug(`stop() initiated.`); 168 | 169 | const { server } = this; 170 | if (!server) { 171 | throw new Error( 172 | "Server is not running! Please make sure that start() was called." 173 | ); 174 | } 175 | 176 | logDebug(`Attempting to shutdown server.`); 177 | await new Promise((resolve, reject) => { 178 | server.close((err) => (err ? reject(err) : resolve())); 179 | }); 180 | logDebug(`Successfully shutdown server.`); 181 | } 182 | 183 | async serve( 184 | node: NodeDescription, 185 | ready: (port: number, path: string) => Promise, 186 | id = uuid.v4() 187 | ): Promise { 188 | logDebug(`serve() initiated with node ID: ${id}`); 189 | 190 | if (!this.server || !this.port) { 191 | throw new Error( 192 | "Server is not running! Please make sure that start() was called." 193 | ); 194 | } 195 | 196 | logDebug(`Storing node.`); 197 | this.nodes[id] = node; 198 | 199 | logDebug(`Rendering node.`); 200 | const result = await ready(this.port, `/render/${id}`); 201 | logDebug(`Node rendered.`); 202 | 203 | logDebug(`Deleting node.`); 204 | delete this.nodes[id]; 205 | 206 | return result; 207 | } 208 | } 209 | 210 | export interface NodeDescription { 211 | name: string; 212 | reactNode: React.ReactNode; 213 | remoteStylesheetUrls: string[]; 214 | } 215 | -------------------------------------------------------------------------------- /src/lib/react/ReactScreenshotTest.ts: -------------------------------------------------------------------------------- 1 | import callsites from "callsites"; 2 | import chalk from "chalk"; 3 | import { existsSync } from "fs"; 4 | import { toMatchImageSnapshot } from "jest-image-snapshot"; 5 | import { dirname, join, sep } from "path"; 6 | import { debugLogger } from "../logger"; 7 | import { fetch } from "../network/fetch"; 8 | import { Viewport } from "../screenshot-renderer/api"; 9 | import { 10 | getScreenshotPrefix, 11 | SCREENSHOT_MODE, 12 | SCREENSHOT_SERVER_URL, 13 | } from "../screenshot-server/config"; 14 | import { ReactComponentServer } from "./ReactComponentServer"; 15 | 16 | const logDebug = debugLogger("ReactScreenshotTest"); 17 | 18 | /** 19 | * ReactScreenshotTest is a builder for screenshot tests. 20 | * 21 | * Example usage: 22 | * ``` 23 | * describe("screenshots", () => { 24 | * ReactScreenshotTest.create("MyComponent") 25 | * .viewports(VIEWPORTS) 26 | * .shoot("with title", ) 27 | * .shoot("without title", ) 28 | * .run(); 29 | * }); 30 | * ``` 31 | */ 32 | export class ReactScreenshotTest { 33 | private readonly _viewports: { 34 | [name: string]: Viewport; 35 | } = {}; 36 | 37 | private readonly _shots: { 38 | [name: string]: React.ReactNode; 39 | } = {}; 40 | 41 | private readonly _remoteStylesheetUrls: string[] = []; 42 | 43 | private readonly _staticPaths: Record = {}; 44 | 45 | private ran = false; 46 | 47 | /** 48 | * Creates a screenshot test. 49 | */ 50 | static create(componentName: string) { 51 | return new this(componentName); 52 | } 53 | 54 | private constructor(private readonly componentName: string) { 55 | setImmediate(() => { 56 | if (!this.ran) { 57 | throw new Error("Please call .run()"); 58 | } 59 | }); 60 | } 61 | 62 | /** 63 | * Adds a set of viewports to the screenshot test. 64 | */ 65 | viewports(viewports: { [name: string]: Viewport }) { 66 | for (const [name, viewport] of Object.entries(viewports)) { 67 | this.viewport(name, viewport); 68 | } 69 | return this; 70 | } 71 | 72 | /** 73 | * Adds a single viewport to the screenshot test. 74 | */ 75 | viewport(viewportName: string, viewport: Viewport) { 76 | if (this.ran) { 77 | throw new Error("Cannot add a viewport after running."); 78 | } 79 | if (this._viewports[viewportName]) { 80 | throw new Error(`Viewport "${viewportName}" is declared more than once`); 81 | } 82 | this._viewports[viewportName] = viewport; 83 | return this; 84 | } 85 | 86 | /** 87 | * Adds a specific shot of a component to the screenshot test. 88 | */ 89 | shoot(shotName: string, component: React.ReactNode) { 90 | if (this.ran) { 91 | throw new Error("Cannot add a shot after running."); 92 | } 93 | if (this._shots[shotName]) { 94 | throw new Error(`Shot "${shotName}" is declared more than once`); 95 | } 96 | this._shots[shotName] = component; 97 | return this; 98 | } 99 | 100 | remoteStylesheet(stylesheetUrl: string) { 101 | this._remoteStylesheetUrls.push(stylesheetUrl); 102 | return this; 103 | } 104 | 105 | static(mappedPath: string, dirOrFilePath: string) { 106 | if (!mappedPath.startsWith("/")) { 107 | throw new Error("Directory mapping path must start with /"); 108 | } 109 | if (!existsSync(dirOrFilePath)) { 110 | throw new Error( 111 | `Could not find path "${dirOrFilePath}". Consider using path.resolve() to get an absolute path.` 112 | ); 113 | } 114 | if (this._staticPaths[mappedPath]) { 115 | throw new Error("Cannot map multiple directories to the same path"); 116 | } 117 | this._staticPaths[mappedPath] = dirOrFilePath; 118 | return this; 119 | } 120 | 121 | /** 122 | * Runs the actual test (delegating to Jest). 123 | */ 124 | run() { 125 | if (this.ran) { 126 | throw new Error("Cannot run more than once."); 127 | } 128 | this.ran = true; 129 | if (Object.keys(this._viewports).length === 0) { 130 | throw new Error("Please define viewports with .viewport()"); 131 | } 132 | if (Object.keys(this._shots).length === 0) { 133 | throw new Error("Please define shots with .shoot()"); 134 | } 135 | 136 | const componentServer = new ReactComponentServer(this._staticPaths); 137 | 138 | expect.extend({ toMatchImageSnapshot }); 139 | 140 | beforeAll(async () => { 141 | await componentServer.start(); 142 | }); 143 | 144 | afterAll(async () => { 145 | await componentServer.stop(); 146 | }); 147 | 148 | const testFilename = callsites()[1].getFileName()!; 149 | const snapshotsDir = dirname(testFilename); 150 | 151 | const prefix = getScreenshotPrefix(); 152 | // jest-image-snapshot doesn't support a snapshot identifier such as 153 | // "abc/def". Instead, we need some logic to look for a directory 154 | // separator (using `sep`) and set the subdirectory to "abc", only using 155 | // "def" as the identifier prefix. 156 | let subdirectory = ""; 157 | let filenamePrefix = ""; 158 | if (prefix.indexOf(sep) > -1) { 159 | [subdirectory, filenamePrefix] = prefix.split(sep, 2); 160 | } else { 161 | filenamePrefix = prefix; 162 | } 163 | 164 | describe(this.componentName, () => { 165 | for (const [viewportName, viewport] of Object.entries(this._viewports)) { 166 | describe(viewportName, () => { 167 | for (const [shotName, shot] of Object.entries(this._shots)) { 168 | it(shotName, async () => { 169 | const name = `${this.componentName} - ${viewportName} - ${shotName}`; 170 | 171 | logDebug( 172 | `Requesting component server to generate screenshot: ${name}` 173 | ); 174 | const screenshot = await componentServer.serve( 175 | { 176 | name, 177 | reactNode: shot, 178 | remoteStylesheetUrls: this._remoteStylesheetUrls, 179 | }, 180 | async (port, path) => { 181 | // docker.interval is only available on window and mac 182 | const url = 183 | SCREENSHOT_MODE === "docker" && process.platform !== "linux" 184 | ? `http://host.docker.internal:${port}${path}` 185 | : `http://localhost:${port}${path}`; 186 | return this.render(name, url, viewport); 187 | } 188 | ); 189 | logDebug(`Screenshot generated.`); 190 | 191 | if (screenshot) { 192 | logDebug(`Comparing screenshot.`); 193 | expect(screenshot).toMatchImageSnapshot({ 194 | customSnapshotsDir: join( 195 | snapshotsDir, 196 | "__screenshots__", 197 | this.componentName, 198 | subdirectory 199 | ), 200 | customSnapshotIdentifier: `${filenamePrefix}${viewportName} - ${shotName}`, 201 | }); 202 | logDebug(`Screenshot compared.`); 203 | } else { 204 | logDebug(`Skipping screenshot matching.`); 205 | } 206 | }); 207 | } 208 | }); 209 | } 210 | }); 211 | } 212 | 213 | private async render(name: string, url: string, viewport: Viewport) { 214 | let response: { 215 | status: number; 216 | body: Buffer; 217 | }; 218 | try { 219 | logDebug( 220 | `Initiating request to screenshot server at ${SCREENSHOT_SERVER_URL}.` 221 | ); 222 | response = await fetch(`${SCREENSHOT_SERVER_URL}/render`, "POST", { 223 | name, 224 | url, 225 | viewport, 226 | }); 227 | } catch (e) { 228 | // eslint-disable-next-line no-console 229 | console.error( 230 | chalk.red( 231 | `Unable to reach screenshot server. Please make sure that your Jest configuration contains the following: 232 | 233 | { 234 | "globalSetup": "react-screenshot-test/global-setup", 235 | "globalTeardown": "react-screenshot-test/global-teardown" 236 | } 237 | ` 238 | ) 239 | ); 240 | throw e; 241 | } 242 | logDebug(`Response received with status code ${response.status}.`); 243 | if (response.status === 204) { 244 | return null; 245 | } 246 | if (response.status !== 200) { 247 | // eslint-disable-next-line no-console 248 | console.error( 249 | chalk.red( 250 | `Screenshot server failed to render (status ${response.status}). 251 | 252 | Error: ${response.body.toString("utf8")} 253 | ` 254 | ) 255 | ); 256 | throw new Error( 257 | `Received response ${response.status} from screenshot server.` 258 | ); 259 | } 260 | return response.body; 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/lib/recorded-assets.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from "uuid"; 2 | 3 | export const ASSET_SERVING_PREFIX = "/assets/"; 4 | 5 | /** 6 | * A map of generated asset path to actual filename. 7 | * 8 | * @example 9 | * { 10 | * "abc-123": "/home/example/project/original.jpg" 11 | * } 12 | */ 13 | const recordedAssets: Record = {}; 14 | 15 | /** 16 | * Record an imported asset. 17 | * 18 | * @returns its future URL on the component server. 19 | */ 20 | export function recordAsset(filePath: string) { 21 | const extensionIndex = filePath.lastIndexOf("."); 22 | if (extensionIndex === -1) { 23 | throw new Error(`Unsupported asset with no extension: ${filePath}`); 24 | } 25 | const extension = filePath.substr(extensionIndex + 1); 26 | const generatedName = `${uuid.v4()}.${extension}`; 27 | recordedAssets[generatedName] = filePath; 28 | return `${ASSET_SERVING_PREFIX}${generatedName}`; 29 | } 30 | 31 | /** 32 | * Returns the original asset file path from a served path. 33 | * 34 | * @param servedPath the component server path (e.g. `/assets/abc-123.jpg`) 35 | * @returns the original filename (e.g. `/home/example/project/original.jpg`) 36 | */ 37 | export function getAssetFilename(servedPath: string) { 38 | if (!servedPath.startsWith(ASSET_SERVING_PREFIX)) { 39 | throw new Error(`Invalid asset path: ${servedPath} (wrong prefix)`); 40 | } 41 | const generatedName = servedPath.substr(ASSET_SERVING_PREFIX.length); 42 | const filePath = recordedAssets[generatedName]; 43 | if (!filePath) { 44 | throw new Error(`Invalid asset path: ${servedPath} (never recorded)`); 45 | } 46 | return filePath; 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/recorded-css.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A concatenated string of all CSS stylesheets imported directly or indirectly from the test. 3 | * 4 | * Transitions and animations are always disabled. 5 | */ 6 | let recordedCss = ` 7 | * { 8 | transition: none !important; 9 | animation: none !important; 10 | } 11 | `; 12 | 13 | /** 14 | * Record an imported CSS stylesheet. 15 | */ 16 | export function recordCss(css: string) { 17 | recordedCss += css; 18 | } 19 | 20 | /** 21 | * Read all CSS stylesheets as a single CSS string. 22 | */ 23 | export function readRecordedCss() { 24 | return recordedCss; 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/screenshot-renderer/PercyScreenshotRenderer.ts: -------------------------------------------------------------------------------- 1 | import { Viewport } from "puppeteer"; 2 | import type PercySnapshot from "@percy/puppeteer"; 3 | import { Browser, launchChrome } from "../browser/chrome"; 4 | import { debugLogger } from "../logger"; 5 | import { ScreenshotRenderer } from "./api"; 6 | 7 | const logDebug = debugLogger("PercyScreenshotRenderer"); 8 | 9 | /** 10 | * A screenshot renderer that uses Percy to take and compare screenshots. 11 | */ 12 | export class PercyScreenshotRenderer implements ScreenshotRenderer { 13 | private browser: Browser | null = null; 14 | 15 | constructor() {} 16 | 17 | async start() { 18 | logDebug(`start() initiated.`); 19 | 20 | logDebug(`Launching Chrome browser.`); 21 | this.browser = await launchChrome(); 22 | logDebug(`Chrome browser launched.`); 23 | } 24 | 25 | async stop() { 26 | logDebug(`stop() initiated.`); 27 | 28 | if (this.browser) { 29 | logDebug(`Closing Chrome browser.`); 30 | await this.browser.close(); 31 | logDebug(`Chrome browser closed.`); 32 | } else { 33 | logDebug(`No Chrome browser found.`); 34 | } 35 | } 36 | 37 | async render(name: string, url: string, viewport?: Viewport) { 38 | logDebug(`render() invoked with (name = ${name}, url = ${url}).`); 39 | 40 | if (!this.browser) { 41 | throw new Error("Browser was not launched successfully."); 42 | } 43 | const page = await this.browser.newPage(); 44 | await page.goto(url); 45 | let percySnapshot: typeof PercySnapshot; 46 | try { 47 | percySnapshot = (await import("@percy/puppeteer")).default; 48 | } catch (e) { 49 | throw new Error( 50 | `Please install the '@percy/puppeteer' package: 51 | 52 | Using NPM: 53 | $ npm install -D @percy/puppeteer 54 | 55 | Using Yarn: 56 | $ yarn add -D @percy/puppeteer` 57 | ); 58 | } 59 | await percySnapshot( 60 | page, 61 | name, 62 | viewport && { 63 | widths: [viewport.width / (viewport.deviceScaleFactor || 1)], 64 | } 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/lib/screenshot-renderer/PuppeteerScreenshotRenderer.spec.ts: -------------------------------------------------------------------------------- 1 | import mockPuppeteer, { Browser, Page } from "puppeteer"; 2 | import { dummy } from "../../testing/dummy"; 3 | import { mocked } from "../../testing/mock"; 4 | import { partialMock } from "../../testing/partial-mock"; 5 | import { PuppeteerScreenshotRenderer } from "./PuppeteerScreenshotRenderer"; 6 | 7 | jest.mock("puppeteer"); 8 | 9 | describe("PuppeteerScreenshotRenderer", () => { 10 | let mockBrowser: jest.Mocked; 11 | let mockPage: jest.Mocked; 12 | 13 | beforeEach(() => { 14 | jest.resetAllMocks(); 15 | mockPage = partialMock({ 16 | goto: jest.fn(), 17 | screenshot: jest.fn(), 18 | setViewport: jest.fn(), 19 | close: jest.fn(), 20 | }); 21 | mockBrowser = partialMock({ 22 | newPage: jest.fn().mockReturnValue(mockPage), 23 | close: jest.fn(), 24 | }); 25 | mocked(mockPuppeteer.launch).mockResolvedValue(mockBrowser); 26 | }); 27 | 28 | describe("start", () => { 29 | it("does not launch the browser if start() isn't called", async () => { 30 | // eslint-disable-next-line no-new 31 | new PuppeteerScreenshotRenderer(); 32 | expect(mockPuppeteer.launch).not.toHaveBeenCalled(); 33 | }); 34 | 35 | it("launches the browser when start() is called", async () => { 36 | const renderer = new PuppeteerScreenshotRenderer(); 37 | await renderer.start(); 38 | expect(mockPuppeteer.launch).toHaveBeenCalled(); 39 | }); 40 | 41 | it("fails to start if browser could not be launched", async () => { 42 | mocked(mockPuppeteer.launch).mockRejectedValue( 43 | new Error("Could not start!") 44 | ); 45 | const renderer = new PuppeteerScreenshotRenderer(); 46 | await expect(renderer.start()).rejects.toEqual( 47 | new Error("Could not start!") 48 | ); 49 | }); 50 | }); 51 | 52 | describe("stop", () => { 53 | it("cannot close the browser without first starting it", async () => { 54 | const renderer = new PuppeteerScreenshotRenderer(); 55 | await expect(renderer.stop()).rejects.toEqual( 56 | new Error( 57 | "Browser is not open! Please make sure that start() was called." 58 | ) 59 | ); 60 | }); 61 | 62 | it("closes the browser when stop() is called", async () => { 63 | const renderer = new PuppeteerScreenshotRenderer(); 64 | await renderer.start(); 65 | await renderer.stop(); 66 | expect(mockBrowser.close).toHaveBeenCalled(); 67 | }); 68 | 69 | it("fails to stop if browser could not be closed", async () => { 70 | mockBrowser.close.mockRejectedValue(new Error("Could not stop!")); 71 | const renderer = new PuppeteerScreenshotRenderer(); 72 | await renderer.start(); 73 | await expect(renderer.stop()).rejects.toEqual( 74 | new Error("Could not stop!") 75 | ); 76 | }); 77 | }); 78 | 79 | describe("render", () => { 80 | it("cannot render without first starting it", async () => { 81 | const renderer = new PuppeteerScreenshotRenderer(); 82 | await expect( 83 | renderer.render("test", "http://example.com") 84 | ).rejects.toEqual(new Error("Please call start() once before render().")); 85 | }); 86 | 87 | it("takes a screenshot", async () => { 88 | const dummyBinaryScreenshot: Buffer = dummy(); 89 | mockPage.screenshot.mockResolvedValue(dummyBinaryScreenshot); 90 | const renderer = new PuppeteerScreenshotRenderer(); 91 | await renderer.start(); 92 | const screenshot = await renderer.render("test", "http://example.com"); 93 | expect(screenshot).toBe(dummyBinaryScreenshot); 94 | expect(mockPage.goto).toHaveBeenCalledWith("http://example.com"); 95 | expect(mockPage.screenshot).toHaveBeenCalledWith({ 96 | encoding: "binary", 97 | fullPage: true, 98 | }); 99 | expect(mockPage.close).toHaveBeenCalled(); 100 | }); 101 | 102 | it("sets the viewport if provided", async () => { 103 | const renderer = new PuppeteerScreenshotRenderer(); 104 | await renderer.start(); 105 | await renderer.render("test", "http://example.com", { 106 | width: 1024, 107 | height: 768, 108 | }); 109 | expect(mockPage.setViewport).toHaveBeenCalledWith({ 110 | width: 1024, 111 | height: 768, 112 | }); 113 | }); 114 | 115 | it("does not set the viewport if not provided", async () => { 116 | const renderer = new PuppeteerScreenshotRenderer(); 117 | await renderer.start(); 118 | await renderer.render("test", "http://example.com"); 119 | expect(mockPage.setViewport).not.toHaveBeenCalled(); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /src/lib/screenshot-renderer/PuppeteerScreenshotRenderer.ts: -------------------------------------------------------------------------------- 1 | import { Browser, launchChrome } from "../browser/chrome"; 2 | import { debugLogger } from "../logger"; 3 | import { ScreenshotRenderer, Viewport } from "./api"; 4 | 5 | const logDebug = debugLogger("PuppeteerScreenshotRenderer"); 6 | 7 | /** 8 | * A screenshot renderer that uses Chrome (via Puppeteer) to take screenshots on 9 | * the current platform. 10 | */ 11 | export class PuppeteerScreenshotRenderer implements ScreenshotRenderer { 12 | private browser: Browser | null = null; 13 | 14 | async start() { 15 | logDebug(`start() initiated.`); 16 | 17 | logDebug(`Launching Chrome browser.`); 18 | this.browser = await launchChrome(); 19 | logDebug(`Chrome browser launched.`); 20 | } 21 | 22 | async stop() { 23 | logDebug(`stop() initiated.`); 24 | 25 | if (!this.browser) { 26 | throw new Error( 27 | "Browser is not open! Please make sure that start() was called." 28 | ); 29 | } 30 | logDebug(`Closing Chrome browser.`); 31 | await this.browser.close(); 32 | logDebug(`Chrome browser closed.`); 33 | } 34 | 35 | async render(name: string, url: string, viewport?: Viewport) { 36 | logDebug(`render() invoked with (name = ${name}, url = ${url}).`); 37 | 38 | if (!this.browser) { 39 | throw new Error("Please call start() once before render()."); 40 | } 41 | const page = await this.browser.newPage(); 42 | if (viewport) { 43 | await page.setViewport(viewport); 44 | } 45 | await page.goto(url); 46 | const screenshot = await page.screenshot({ 47 | encoding: "binary", 48 | fullPage: true, 49 | }); 50 | await page.close(); 51 | return screenshot; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/screenshot-renderer/WebdriverScreenshotRenderer.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess } from "child_process"; 2 | import selenium from "selenium-standalone"; 3 | import * as webdriverio from "webdriverio"; 4 | import { ScreenshotRenderer, Viewport } from "./api"; 5 | import { debugLogger } from "../logger"; 6 | 7 | const logDebug = debugLogger("SeleniumScreenshotRenderer"); 8 | 9 | /** 10 | * A screenshot renderer that uses a browser controlled by Selenium to take 11 | * screenshots on the current platform. 12 | */ 13 | export class SeleniumScreenshotRenderer implements ScreenshotRenderer { 14 | private seleniumProcess: ChildProcess | null = null; 15 | 16 | private browser: WebdriverIOAsync.BrowserObject | null = null; 17 | 18 | constructor(private readonly capabilities: WebDriver.DesiredCapabilities) {} 19 | 20 | async start() { 21 | logDebug(`start() initiated.`); 22 | 23 | logDebug(`Ensuring that Selenium is installed.`); 24 | await new Promise((resolve) => { 25 | selenium.install(resolve); 26 | }); 27 | logDebug(`Selenium is installed.`); 28 | 29 | logDebug(`Starting Selenium server.`); 30 | this.seleniumProcess = await new Promise((resolve, reject) => 31 | selenium.start((error, childProcess) => { 32 | if (error) { 33 | reject(error); 34 | } else { 35 | resolve(childProcess); 36 | } 37 | }) 38 | ); 39 | logDebug(`Selenium server started.`); 40 | 41 | logDebug(`Launching browser.`); 42 | this.browser = await webdriverio.remote({ 43 | capabilities: this.capabilities, 44 | logLevel: "warn", 45 | }); 46 | logDebug(`Browser launched.`); 47 | } 48 | 49 | async stop() { 50 | logDebug(`stop() initiated.`); 51 | 52 | if (!this.browser) { 53 | throw new Error( 54 | "Browser is not open! Please make sure that start() was called." 55 | ); 56 | } 57 | await this.browser.closeWindow(); 58 | if (this.seleniumProcess) { 59 | // Kill Selenium server. 60 | await this.seleniumProcess.kill(); 61 | } 62 | } 63 | 64 | async render(name: string, url: string, viewport?: Viewport) { 65 | logDebug(`render() invoked with (name = ${name}, url = ${url}).`); 66 | 67 | if (!this.browser) { 68 | throw new Error("Please call start() once before render()."); 69 | } 70 | if (viewport) { 71 | this.browser.setWindowSize( 72 | viewport.isLandscape ? viewport.height : viewport.width, 73 | viewport.isLandscape ? viewport.width : viewport.height 74 | ); 75 | } 76 | await this.browser.navigateTo(url); 77 | return Buffer.from(await this.browser.takeScreenshot(), "base64"); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/screenshot-renderer/api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A screenshot renderer takes screenshots of arbitrary URLs. 3 | * 4 | * This is an abstraction around a browser. In particular, it allows us to wrap 5 | * Chrome within Docker to ensure screenshots are consistent even when tests are 6 | * run from a different platform (e.g. Mac OS). 7 | */ 8 | export interface ScreenshotRenderer { 9 | start(): Promise; 10 | stop(): Promise; 11 | 12 | /** 13 | * Returns a buffer representing a PNG screenshot, or `null` when screenshot 14 | * comparison is delegated to a third-party. 15 | */ 16 | render( 17 | name: string, 18 | url: string, 19 | viewport?: Viewport 20 | ): Promise; 21 | } 22 | 23 | export interface Viewport { 24 | /** The page width in pixels. */ 25 | width: number; 26 | /** The page height in pixels. */ 27 | height: number; 28 | /** 29 | * Specify device scale factor (can be thought of as dpr). 30 | * @default 1 31 | */ 32 | deviceScaleFactor?: number; 33 | /** 34 | * Whether the `meta viewport` tag is taken into account. 35 | * @default false 36 | */ 37 | isMobile?: boolean; 38 | /** 39 | * Specifies if viewport supports touch events. 40 | * @default false 41 | */ 42 | hasTouch?: boolean; 43 | /** 44 | * Specifies if viewport is in landscape mode. 45 | * @default false 46 | */ 47 | isLandscape?: boolean; 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/screenshot-server/DockerizedScreenshotServer.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import Docker from "dockerode"; 3 | import { debugLogger } from "../logger"; 4 | import { ScreenshotServer } from "./api"; 5 | import { getLoggingLevel } from "./config"; 6 | 7 | const DOCKER_IMAGE_TAG_NAME = "fwouts/chrome-screenshot"; 8 | const DOCKER_IMAGE_VERSION = "1.2.2"; 9 | const DOCKER_IMAGE_TAG = `${DOCKER_IMAGE_TAG_NAME}:${DOCKER_IMAGE_VERSION}`; 10 | 11 | const logDebug = debugLogger("DockerizedScreenshotServer"); 12 | 13 | /** 14 | * A screenshot server running inside a Docker container (which runs Chrome) to 15 | * ensure that screenshots are consistent across platforms. 16 | */ 17 | export class DockerizedScreenshotServer implements ScreenshotServer { 18 | private readonly docker: Docker; 19 | 20 | private container: Docker.Container | null = null; 21 | 22 | constructor(private readonly port: number) { 23 | this.docker = new Docker({ 24 | socketPath: 25 | process.platform === "win32" 26 | ? "//./pipe/docker_engine" 27 | : "/var/run/docker.sock", 28 | }); 29 | } 30 | 31 | async start() { 32 | logDebug(`DockerizedScreenshotServer.start() initiated.`); 33 | if (this.container) { 34 | throw new Error( 35 | "Container is already started! Please only call start() once." 36 | ); 37 | } 38 | 39 | logDebug(`Ensuring that Docker image is present.`); 40 | await ensureDockerImagePresent(this.docker); 41 | 42 | logDebug(`Removing any old Docker containers.`); 43 | await removeLeftoverContainers(this.docker); 44 | 45 | logDebug(`Starting Docker container.`); 46 | this.container = await startContainer(this.docker, this.port); 47 | logDebug(`Docker container started.`); 48 | } 49 | 50 | async stop() { 51 | logDebug(`DockerizedScreenshotServer.stop() initiated.`); 52 | if (!this.container) { 53 | throw new Error( 54 | "Container is not started! Please make sure that start() was called." 55 | ); 56 | } 57 | 58 | logDebug(`Killing Docker container.`); 59 | await this.container.kill(); 60 | logDebug(`Docker container killed.`); 61 | 62 | logDebug(`Removing Docker container.`); 63 | await this.container.remove(); 64 | logDebug(`Docker container removed.`); 65 | } 66 | } 67 | 68 | async function ensureDockerImagePresent(docker: Docker) { 69 | const images = await docker.listImages({ 70 | filters: { 71 | reference: { 72 | [DOCKER_IMAGE_TAG]: true, 73 | }, 74 | }, 75 | }); 76 | if (images.length === 0) { 77 | throw new Error( 78 | `It looks like you're missing the Docker image required to render screenshots.\n\nPlease run the following command:\n\n$ docker pull ${DOCKER_IMAGE_TAG}\n\n` 79 | ); 80 | } 81 | } 82 | 83 | async function removeLeftoverContainers(docker: Docker) { 84 | const existingContainers = await docker.listContainers(); 85 | for (const existingContainerInfo of existingContainers) { 86 | const [name] = existingContainerInfo.Image.split(":"); 87 | if (name === DOCKER_IMAGE_TAG_NAME) { 88 | // eslint-disable-next-line no-await-in-loop 89 | const existingContainer = await docker.getContainer( 90 | existingContainerInfo.Id 91 | ); 92 | if (existingContainerInfo.State === "running") { 93 | // eslint-disable-next-line no-await-in-loop 94 | await existingContainer.stop(); 95 | } 96 | // eslint-disable-next-line no-await-in-loop 97 | await existingContainer.remove(); 98 | } 99 | } 100 | } 101 | 102 | async function startContainer(docker: Docker, port: number) { 103 | let hostConfig: Docker.ContainerCreateOptions["HostConfig"] = { 104 | PortBindings: { 105 | "3001/tcp": [{ HostPort: `${port}` }], 106 | }, 107 | }; 108 | if (process.platform === "linux") { 109 | hostConfig = { 110 | NetworkMode: "host", 111 | }; 112 | } 113 | 114 | const container = await docker.createContainer({ 115 | Image: DOCKER_IMAGE_TAG, 116 | AttachStdin: false, 117 | AttachStdout: true, 118 | AttachStderr: true, 119 | Tty: true, 120 | OpenStdin: false, 121 | StdinOnce: false, 122 | ExposedPorts: { 123 | "3001/tcp": {}, 124 | }, 125 | Env: [`SCREENSHOT_LOGGING_LEVEL=${getLoggingLevel()}`], 126 | HostConfig: hostConfig, 127 | }); 128 | await container.start(); 129 | const stream = await container.logs({ 130 | stdout: true, 131 | stderr: true, 132 | follow: true, 133 | }); 134 | await new Promise((resolve) => { 135 | stream.on("data", (message) => { 136 | if (getLoggingLevel() === "DEBUG") { 137 | console.log(chalk.yellow(`Docker container output:\n${message}`)); 138 | } 139 | if (message.toString().indexOf("Ready.") > -1) { 140 | resolve(); 141 | } 142 | }); 143 | }); 144 | return container; 145 | } 146 | -------------------------------------------------------------------------------- /src/lib/screenshot-server/LocalScreenshotServer.spec.ts: -------------------------------------------------------------------------------- 1 | import getPort from "get-port"; 2 | import { partialMock } from "../../testing/partial-mock"; 3 | import { ScreenshotRenderer } from "../screenshot-renderer/api"; 4 | import { LocalScreenshotServer } from "./LocalScreenshotServer"; 5 | import { fetch } from "../network/fetch"; 6 | 7 | describe("LocalScreenshotServer", () => { 8 | let mockRenderer: jest.Mocked; 9 | 10 | beforeEach(() => { 11 | jest.resetAllMocks(); 12 | mockRenderer = partialMock({ 13 | start: jest.fn(), 14 | stop: jest.fn(), 15 | render: jest.fn(), 16 | }); 17 | }); 18 | 19 | it("renders with viewport", async () => { 20 | const port = await getPort(); 21 | const server = new LocalScreenshotServer(mockRenderer, port); 22 | 23 | await server.start(); 24 | expect(mockRenderer.start).toHaveBeenCalled(); 25 | 26 | await fetch(`http://localhost:${port}/render`, "POST", { 27 | name: "screenshot", 28 | url: "http://example.com", 29 | viewport: { 30 | with: 1024, 31 | height: 768, 32 | }, 33 | }); 34 | expect(mockRenderer.render).toHaveBeenCalledWith( 35 | "screenshot", 36 | "http://example.com", 37 | { 38 | with: 1024, 39 | height: 768, 40 | } 41 | ); 42 | 43 | await server.stop(); 44 | expect(mockRenderer.stop).toHaveBeenCalled(); 45 | }); 46 | 47 | it("renders without viewport", async () => { 48 | const port = await getPort(); 49 | const server = new LocalScreenshotServer(mockRenderer, port); 50 | 51 | await server.start(); 52 | expect(mockRenderer.start).toHaveBeenCalled(); 53 | 54 | await fetch(`http://localhost:${port}/render`, "POST", { 55 | name: "screenshot", 56 | url: "http://example.com", 57 | }); 58 | expect(mockRenderer.render).toHaveBeenCalledWith( 59 | "screenshot", 60 | "http://example.com" 61 | ); 62 | 63 | await server.stop(); 64 | expect(mockRenderer.stop).toHaveBeenCalled(); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/lib/screenshot-server/LocalScreenshotServer.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from "body-parser"; 2 | import express, { Express } from "express"; 3 | import { Server } from "net"; 4 | import { debugLogger } from "../logger"; 5 | import { ScreenshotRenderer } from "../screenshot-renderer/api"; 6 | import { ScreenshotServer } from "./api"; 7 | 8 | const logDebug = debugLogger("LocalScreenshotServer"); 9 | 10 | /** 11 | * A local server with a /render POST endpoint, which takes a payload such as 12 | * 13 | * ```json 14 | * { 15 | * "url": "https://www.google.com" 16 | * } 17 | * ``` 18 | * 19 | * and returns a PNG screenshot of the URL. 20 | */ 21 | export class LocalScreenshotServer implements ScreenshotServer { 22 | private readonly app: Express; 23 | 24 | private server: Server | null = null; 25 | 26 | constructor( 27 | private readonly renderer: ScreenshotRenderer, 28 | private readonly port: number 29 | ) { 30 | this.app = express(); 31 | this.app.use(bodyParser.json()); 32 | this.app.post("/render", async (req, res) => { 33 | const { name, url, viewport } = req.body; 34 | try { 35 | const screenshot = await (viewport 36 | ? this.renderer.render(name, url, viewport) 37 | : this.renderer.render(name, url)); 38 | if (screenshot) { 39 | res.contentType("image/png"); 40 | res.end(screenshot); 41 | } else { 42 | res.status(204); 43 | res.end(); 44 | } 45 | } catch (e) { 46 | res.status(500); 47 | // eslint-disable-next-line no-console 48 | console.error(e); 49 | res.end(e.message || `Unable to render a screenshot of ${url}`); 50 | } 51 | }); 52 | } 53 | 54 | async start() { 55 | logDebug(`start() initiated.`); 56 | 57 | logDebug(`Starting renderer.`); 58 | await this.renderer.start(); 59 | logDebug(`Renderer started.`); 60 | 61 | logDebug(`Attempting to listen on port ${this.port}.`); 62 | await new Promise((resolve) => { 63 | this.server = this.app.listen(this.port, resolve); 64 | }); 65 | logDebug(`Successfully listening on port ${this.port}.`); 66 | } 67 | 68 | async stop() { 69 | logDebug(`stop() initiated.`); 70 | 71 | const { server } = this; 72 | if (!server) { 73 | throw new Error( 74 | "Server is not running! Please make sure that start() was called." 75 | ); 76 | } 77 | 78 | logDebug(`Attempting to shutdown server.`); 79 | await new Promise((resolve, reject) => { 80 | server.close((err) => (err ? reject(err) : resolve())); 81 | }); 82 | logDebug(`Successfully shutdown server.`); 83 | 84 | logDebug(`Stopping renderer.`); 85 | await this.renderer.stop(); 86 | logDebug(`Renderer stopped.`); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/lib/screenshot-server/api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A screenshot server takes screenshots of arbitrary URLs. 3 | * 4 | * The server should expose a POST /render endpoint that accepts a JSON payload 5 | * containing the URL to render. 6 | */ 7 | export interface ScreenshotServer { 8 | start(): Promise; 9 | stop(): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/screenshot-server/config.ts: -------------------------------------------------------------------------------- 1 | import isDocker from "is-docker"; 2 | import { LoggingConfig } from "../logger"; 3 | 4 | export const SCREENSHOT_MODE = getScreenshotMode(); 5 | 6 | const serverDefaultPort = 7 | process.platform === "linux" && SCREENSHOT_MODE === "docker" 8 | ? "3001" 9 | : "3038"; 10 | export const SCREENSHOT_SERVER_PORT = parseInt( 11 | process.env.SCREENSHOT_SERVER_PORT || serverDefaultPort, 12 | 10 13 | ); 14 | 15 | export const SCREENSHOT_SERVER_URL = 16 | process.env.SCREENSHOT_SERVER_URL || 17 | `http://localhost:${SCREENSHOT_SERVER_PORT}`; 18 | 19 | function getScreenshotMode(): "puppeteer" | "selenium" | "docker" | "percy" { 20 | if (process.env.SCREENSHOT_MODE) { 21 | switch (process.env.SCREENSHOT_MODE) { 22 | case "local": 23 | case "puppeteer": 24 | return "puppeteer"; 25 | case "selenium": 26 | case "docker": 27 | case "percy": 28 | return process.env.SCREENSHOT_MODE; 29 | default: 30 | throw new Error( 31 | `Valid values for SCREENSHOT_MODE are 'puppeteer', 'selenium', 'docker' and 'percy'. Received '${process.env.SCREENSHOT_MODE}'.` 32 | ); 33 | } 34 | } 35 | return isDocker() ? "puppeteer" : "docker"; 36 | } 37 | 38 | export function getSeleniumBrowser() { 39 | const browser = process.env.SCREENSHOT_SELENIUM_BROWSER; 40 | if (!browser) { 41 | throw new Error( 42 | `Please set SCREENSHOT_SELENIUM_BROWSER. Valid values are "chrome", "firefox", "internet explorer", "opera" or "safari".` 43 | ); 44 | } 45 | return browser; 46 | } 47 | 48 | export function getScreenshotPrefix() { 49 | return process.env.SCREENSHOT_PREFIX || ""; 50 | } 51 | 52 | export function getLoggingLevel(): LoggingConfig { 53 | return (process.env.SCREENSHOT_LOGGING_LEVEL || "").toLowerCase() === "debug" 54 | ? "DEBUG" 55 | : "NORMAL"; 56 | } 57 | -------------------------------------------------------------------------------- /src/testing/dummy.ts: -------------------------------------------------------------------------------- 1 | export function dummy(): T { 2 | return {} as T; 3 | } 4 | -------------------------------------------------------------------------------- /src/testing/mock.ts: -------------------------------------------------------------------------------- 1 | export function mocked any>( 2 | f: T 3 | ): jest.MockedFunction { 4 | if (!jest.isMockFunction(f)) { 5 | throw new Error("Expected a mock, but found a real function."); 6 | } 7 | return f; 8 | } 9 | -------------------------------------------------------------------------------- /src/testing/partial-mock.ts: -------------------------------------------------------------------------------- 1 | export function partialMock( 2 | partial: Partial> 3 | ): jest.Mocked { 4 | return partial as jest.Mocked; 5 | } 6 | -------------------------------------------------------------------------------- /src/tests/__screenshots__/Animated components/Desktop - animated-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Animated components/Desktop - animated-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Animated components/iPhone X - animated-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Animated components/iPhone X - animated-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Images/Desktop - PNG-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Images/Desktop - PNG-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Images/Desktop - SVG-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Images/Desktop - SVG-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Images/Desktop - Static image-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Images/Desktop - Static image-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Images/iPhone X - PNG-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Images/iPhone X - PNG-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Images/iPhone X - SVG-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Images/iPhone X - SVG-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Images/iPhone X - Static image-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Images/iPhone X - Static image-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Remote stylesheet/Desktop - basic div-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Remote stylesheet/Desktop - basic div-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Remote stylesheet/iPhone X - basic div-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Remote stylesheet/iPhone X - basic div-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Simple HTML/Desktop - basic div-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Simple HTML/Desktop - basic div-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Simple HTML/iPhone X - basic div-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Simple HTML/iPhone X - basic div-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/Desktop - CSS modules green-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/Desktop - CSS modules green-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/Desktop - CSS modules red-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/Desktop - CSS modules red-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/Desktop - SASS green-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/Desktop - SASS green-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/Desktop - emotion CSS-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/Desktop - emotion CSS-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/Desktop - global CSS blue-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/Desktop - global CSS blue-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/Desktop - global CSS orange-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/Desktop - global CSS orange-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/Desktop - inline style CSS-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/Desktop - inline style CSS-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/Desktop - styled-components CSS-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/Desktop - styled-components CSS-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/iPhone X - CSS modules green-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/iPhone X - CSS modules green-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/iPhone X - CSS modules red-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/iPhone X - CSS modules red-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/iPhone X - SASS green-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/iPhone X - SASS green-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/iPhone X - emotion CSS-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/iPhone X - emotion CSS-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/iPhone X - global CSS blue-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/iPhone X - global CSS blue-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/iPhone X - global CSS orange-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/iPhone X - global CSS orange-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/iPhone X - inline style CSS-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/iPhone X - inline style CSS-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Styled components/iPhone X - styled-components CSS-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Styled components/iPhone X - styled-components CSS-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Unicode/Desktop - Chinese-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Unicode/Desktop - Chinese-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Unicode/Desktop - Emoji-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Unicode/Desktop - Emoji-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Unicode/Desktop - French-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Unicode/Desktop - French-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Unicode/Desktop - Japanese-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Unicode/Desktop - Japanese-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Unicode/iPhone X - Chinese-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Unicode/iPhone X - Chinese-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Unicode/iPhone X - Emoji-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Unicode/iPhone X - Emoji-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Unicode/iPhone X - French-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Unicode/iPhone X - French-snap.png -------------------------------------------------------------------------------- /src/tests/__screenshots__/Unicode/iPhone X - Japanese-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/__screenshots__/Unicode/iPhone X - Japanese-snap.png -------------------------------------------------------------------------------- /src/tests/animated.screenshot.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ReactScreenshotTest } from "../lib"; 3 | import { AnimatedComponent } from "./components/animated"; 4 | import { VIEWPORTS } from "./viewports"; 5 | 6 | describe("screenshots", () => { 7 | ReactScreenshotTest.create("Animated components") 8 | .viewports(VIEWPORTS) 9 | .shoot("animated", ) 10 | .run(); 11 | }); 12 | -------------------------------------------------------------------------------- /src/tests/components/animated.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ClipLoader } from "react-spinners"; 3 | 4 | export const AnimatedComponent = () => ; 5 | -------------------------------------------------------------------------------- /src/tests/components/css-modules-green.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 16px; 3 | } 4 | 5 | .styledButton { 6 | background: #0a0; 7 | color: #000; 8 | padding: 8px; 9 | border-radius: 4px; 10 | font-size: 2em; 11 | } 12 | -------------------------------------------------------------------------------- /src/tests/components/css-modules-green.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styles from "./css-modules-green.module.css"; 3 | 4 | export const CssModulesGreenComponent = () => ( 5 |
6 | 9 |
10 | ); 11 | -------------------------------------------------------------------------------- /src/tests/components/css-modules-red.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 16px; 3 | } 4 | 5 | .styledButton { 6 | background: #f00; 7 | color: #fff; 8 | padding: 8px; 9 | border-radius: 4px; 10 | font-size: 2em; 11 | } 12 | -------------------------------------------------------------------------------- /src/tests/components/css-modules-red.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styles from "./css-modules-red.module.css"; 3 | 4 | export const CssModulesRedComponent = () => ( 5 |
6 | 9 |
10 | ); 11 | -------------------------------------------------------------------------------- /src/tests/components/emotion.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import React from "react"; 3 | 4 | const Container = styled.div` 5 | padding: 16px; 6 | `; 7 | 8 | const Button = styled.button` 9 | background: #00f; 10 | color: #fff; 11 | padding: 8px; 12 | border-radius: 4px; 13 | font-size: 2em; 14 | `; 15 | 16 | export const EmotionComponent = () => ( 17 | 18 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /src/tests/components/global-css-blue.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./global-style-blue.css"; 3 | 4 | export const GlobalCssBlueComponent = () => ( 5 |
6 | 9 |
10 | ); 11 | -------------------------------------------------------------------------------- /src/tests/components/global-css-orange.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./global-style-orange.css"; 3 | 4 | export const GlobalCssOrangeComponent = () => ( 5 |
6 | 9 |
10 | ); 11 | -------------------------------------------------------------------------------- /src/tests/components/global-style-blue.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 16px; 3 | } 4 | 5 | .styledButton { 6 | background: #00f; 7 | color: #fff; 8 | padding: 8px; 9 | border-radius: 4px; 10 | font-size: 2em; 11 | } 12 | -------------------------------------------------------------------------------- /src/tests/components/global-style-orange.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 16px; 3 | } 4 | 5 | .styledButton { 6 | background: #f80; 7 | color: #000; 8 | padding: 8px; 9 | border-radius: 4px; 10 | font-size: 2em; 11 | } 12 | -------------------------------------------------------------------------------- /src/tests/components/inline-style.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const InlineStyleComponent = () => ( 4 |
9 | 20 |
21 | ); 22 | -------------------------------------------------------------------------------- /src/tests/components/png.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import logo from "../../../brand/social.png"; 4 | 5 | const Image = styled.img` 6 | width: 100vw; 7 | `; 8 | 9 | export const PngComponent = () => ; 10 | -------------------------------------------------------------------------------- /src/tests/components/sass-constants.scss: -------------------------------------------------------------------------------- 1 | $paddingSize: 16px; 2 | -------------------------------------------------------------------------------- /src/tests/components/sass-green.scss: -------------------------------------------------------------------------------- 1 | @import "./sass-constants.scss"; 2 | 3 | .sass-container { 4 | padding: $paddingSize; 5 | 6 | .styledButton { 7 | background: #0a0; 8 | color: #000; 9 | padding: 8px; 10 | border-radius: 4px; 11 | font-size: 2em; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/tests/components/sass-green.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./sass-green.scss"; 3 | 4 | export const SassGreenComponent = () => ( 5 |
6 | 9 |
10 | ); 11 | -------------------------------------------------------------------------------- /src/tests/components/static-image.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Image = styled.img` 5 | width: 100vw; 6 | `; 7 | 8 | export const StaticImageComponent = () => ; 9 | -------------------------------------------------------------------------------- /src/tests/components/styled-components.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const Container = styled.div` 5 | padding: 16px; 6 | `; 7 | 8 | const Button = styled.button` 9 | background: #00f; 10 | color: #fff; 11 | padding: 8px; 12 | border-radius: 4px; 13 | font-size: 2em; 14 | `; 15 | 16 | export const StyledComponentsComponent = () => ( 17 | 18 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /src/tests/components/svg.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import logo from "../../../brand/social.svg"; 4 | 5 | const Image = styled.img` 6 | width: 100vw; 7 | `; 8 | 9 | export const SvgComponent = () => ; 10 | -------------------------------------------------------------------------------- /src/tests/global-style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 16px; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | -------------------------------------------------------------------------------- /src/tests/images.screenshot.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ReactScreenshotTest } from "../lib"; 3 | import { PngComponent } from "./components/png"; 4 | import { StaticImageComponent } from "./components/static-image"; 5 | import { SvgComponent } from "./components/svg"; 6 | import { VIEWPORTS } from "./viewports"; 7 | 8 | describe("screenshots", () => { 9 | ReactScreenshotTest.create("Images") 10 | .viewports(VIEWPORTS) 11 | .static("/public", "src/tests/public") 12 | .shoot("PNG", ) 13 | .shoot("SVG", ) 14 | .shoot("Static image", ) 15 | .run(); 16 | }); 17 | -------------------------------------------------------------------------------- /src/tests/public/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwouts/react-screenshot-test/97e8f8bb28ef0c62cb357e0c4113f036fb32b5dc/src/tests/public/react.png -------------------------------------------------------------------------------- /src/tests/remote-stylesheet.screenshot.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ReactScreenshotTest } from "../lib"; 3 | import { VIEWPORTS } from "./viewports"; 4 | 5 | describe("screenshots", () => { 6 | ReactScreenshotTest.create("Remote stylesheet") 7 | .viewports(VIEWPORTS) 8 | .remoteStylesheet( 9 | "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" 10 | ) 11 | .shoot("basic div",
Simple element
) 12 | .run(); 13 | }); 14 | -------------------------------------------------------------------------------- /src/tests/simple.screenshot.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ReactScreenshotTest } from "../lib"; 3 | import { VIEWPORTS } from "./viewports"; 4 | 5 | describe("screenshots", () => { 6 | ReactScreenshotTest.create("Simple HTML") 7 | .viewports(VIEWPORTS) 8 | .shoot("basic div",
Simple element
) 9 | .run(); 10 | }); 11 | -------------------------------------------------------------------------------- /src/tests/styled.screenshot.tsx: -------------------------------------------------------------------------------- 1 | import "normalize.css"; 2 | import React from "react"; 3 | import { ReactScreenshotTest } from "../lib"; 4 | import { CssModulesGreenComponent } from "./components/css-modules-green"; 5 | import { CssModulesRedComponent } from "./components/css-modules-red"; 6 | import { EmotionComponent } from "./components/emotion"; 7 | import { GlobalCssBlueComponent } from "./components/global-css-blue"; 8 | import { GlobalCssOrangeComponent } from "./components/global-css-orange"; 9 | import { InlineStyleComponent } from "./components/inline-style"; 10 | import { SassGreenComponent } from "./components/sass-green"; 11 | import { StyledComponentsComponent } from "./components/styled-components"; 12 | import "./global-style.css"; 13 | import { VIEWPORTS } from "./viewports"; 14 | 15 | describe("screenshots", () => { 16 | ReactScreenshotTest.create("Styled components") 17 | .viewports(VIEWPORTS) 18 | .shoot("inline style CSS", ) 19 | .shoot("emotion CSS", ) 20 | .shoot("styled-components CSS", ) 21 | // Note: we intentionally use components that use the same class name. This is 22 | // used to highlight conflicts, which are expected to occur when two 23 | // components that use global CSS imports have conflicting class names. 24 | .shoot("global CSS orange", ) 25 | // This will end up orange instead of blue! 26 | .shoot("global CSS blue", ) 27 | // CSS modules components should not conflict, because a new classname is 28 | // generated for each. 29 | .shoot("CSS modules red", ) 30 | .shoot("CSS modules green", ) 31 | .shoot("SASS green", ) 32 | .run(); 33 | }); 34 | -------------------------------------------------------------------------------- /src/tests/unicode.screenshot.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ReactScreenshotTest } from "../lib"; 3 | import { VIEWPORTS } from "./viewports"; 4 | 5 | describe("screenshots", () => { 6 | ReactScreenshotTest.create("Unicode") 7 | .viewports(VIEWPORTS) 8 | .shoot("French",
Bonjour Sébastien, comment ça va ?
) 9 | .shoot("Chinese",
你好!
) 10 | .shoot("Japanese",
こんにちは
) 11 | // Disabled as Docker currently doesn't have the right font. 12 | // .shoot("Emoji",
😃
) 13 | .run(); 14 | }); 15 | -------------------------------------------------------------------------------- /src/tests/viewports.ts: -------------------------------------------------------------------------------- 1 | import { devices } from "puppeteer"; 2 | import { Viewport } from "../lib"; 3 | 4 | export const VIEWPORTS: { 5 | [name: string]: Viewport; 6 | } = { 7 | Desktop: { 8 | width: 1024, 9 | height: 768, 10 | }, 11 | "iPhone X": devices["iPhone X"].viewport, 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 11 | "declaration": true /* Generates corresponding '.d.ts' file. */, 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "dist/" /* Redirect output structure to the directory. */, 16 | "rootDir": "src/" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true /* Enable all strict type-checking options. */, 27 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 28 | "strictNullChecks": true /* Enable strict null checks. */, 29 | "strictFunctionTypes": true /* Enable strict checking of function types. */, 30 | "strictBindCallApply": true /* Enable strict 'bind', 'call', and 'apply' methods on functions. */, 31 | "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 32 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 33 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 34 | 35 | /* Additional Checks */ 36 | "noUnusedLocals": true /* Report errors on unused locals. */, 37 | "noUnusedParameters": true /* Report errors on unused parameters. */, 38 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 39 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | "baseUrl": "./" /* Base directory to resolve non-absolute module names. */, 44 | "paths": { 45 | "@percy/script": ["src/percy.d.ts"] 46 | } /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */, 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | } 65 | } 66 | --------------------------------------------------------------------------------