├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .eslintrc.react.yml ├── .eslintrc.yml ├── .github ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── .gitattributes ├── .gitignore └── pre-commit ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── TESTS.md ├── __tests__ ├── assets │ ├── index.css │ └── page-object-model.js ├── click-follow-button-should-scroll-to-bottom.html ├── click-follow-button-should-scroll-to-bottom.js ├── favicon.ico ├── race-condition-append-while-scrolling.html ├── race-condition-append-while-scrolling.js ├── race-condition-scroll-into-view.html ├── race-condition-scroll-into-view.js ├── resize.html ├── resize.js ├── scroll-up-should-show-follow-button.html ├── scroll-up-should-show-follow-button.js ├── serve.json ├── should-render.html ├── should-render.js ├── should-respect-container-change.html ├── should-respect-container-change.js ├── should-respect-nonce-change.html ├── should-respect-nonce-change.js ├── should-respect-options.html ├── should-respect-options.js ├── simple.html ├── simple.js ├── use-at-x.html ├── use-at-x.js ├── use-observe-scroll-position.html ├── use-observe-scroll-position.js ├── use-scroll-to-x.html ├── use-scroll-to-x.js ├── use-scroll-to.html ├── use-scroll-to.js ├── use-sticky.html └── use-sticky.js ├── docker-compose.yml ├── docs └── demo.gif ├── jest.config.js ├── lerna.json ├── lint-staged.config.js ├── package-lock.json ├── package.json ├── packages ├── component │ ├── .eslintrc.yml │ ├── .gitignore │ ├── .prettierrc.yml │ ├── babel.cjs.config.json │ ├── babel.config.json │ ├── babel.esm.config.json │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── BasicScrollToBottom.js │ │ ├── EventSpy.js │ │ ├── ScrollToBottom │ │ ├── AutoHideFollowButton.js │ │ ├── Composer.js │ │ ├── FunctionContext.js │ │ ├── InternalContext.js │ │ ├── Panel.js │ │ ├── State1Context.js │ │ ├── State2Context.js │ │ └── StateContext.js │ │ ├── SpineTo.js │ │ ├── addVersionToMetaTag.js │ │ ├── browser.js │ │ ├── createCSSKey.js │ │ ├── debounce.js │ │ ├── hooks │ │ ├── internal │ │ │ ├── useEmotion.js │ │ │ ├── useFunctionContext.js │ │ │ ├── useInternalContext.js │ │ │ ├── useStateContext.js │ │ │ ├── useStateRef.js │ │ │ └── useStyleToClassName.js │ │ ├── useAnimating.js │ │ ├── useAnimatingToEnd.js │ │ ├── useAtBottom.js │ │ ├── useAtEnd.js │ │ ├── useAtStart.js │ │ ├── useAtTop.js │ │ ├── useMode.js │ │ ├── useObserveScrollPosition.js │ │ ├── useScrollTo.js │ │ ├── useScrollToBottom.js │ │ ├── useScrollToEnd.js │ │ ├── useScrollToStart.js │ │ ├── useScrollToTop.js │ │ └── useSticky.js │ │ ├── index.js │ │ └── utils │ │ ├── debug.js │ │ └── styleConsole.js ├── playground │ ├── .gitignore │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ ├── serve.json │ └── src │ │ ├── App.js │ │ ├── CommandBar.js │ │ ├── StatusBar.js │ │ ├── index.js │ │ ├── reportWebVitals.js │ │ └── setupTests.js └── test-harness │ ├── .gitignore │ ├── JestEnvironment.js │ ├── babel.config.json │ ├── package-lock.json │ ├── package.json │ └── src │ ├── browser │ ├── assertions │ │ ├── became.js │ │ └── stabilized.js │ ├── globals │ │ ├── became.js │ │ ├── expect.js │ │ ├── host.js │ │ ├── run.js │ │ ├── sleep.js │ │ ├── stabilized.js │ │ ├── webDriver.js │ │ └── webDriverPort.js │ ├── index.js │ └── proxies │ │ └── host.js │ ├── common │ ├── marshal.js │ ├── rpc.js │ ├── unmarshal.js │ └── utils │ │ ├── signalToReject.js │ │ └── sleep.js │ └── host │ ├── common │ ├── createHostBridge.js │ ├── createProxies.js │ ├── dumpLogs.js │ ├── getBrowserLogs.js │ ├── host │ │ ├── done.js │ │ ├── error.js │ │ ├── getLogs.js │ │ ├── index.js │ │ ├── ready.js │ │ └── snapshot.js │ ├── registerProxies.js │ └── webDriver │ │ ├── click.js │ │ ├── index.js │ │ ├── performActions.js │ │ ├── sendDevToolsCommand.js │ │ ├── takeScreenshot.js │ │ └── windowSize.js │ ├── dev │ ├── createDevProxies.js │ ├── hostOverrides │ │ ├── done.js │ │ ├── error.js │ │ └── snapshot.js │ ├── index.js │ ├── utils │ │ ├── findHostIP.js │ │ ├── findLocalIP.js │ │ ├── isWSL2.js │ │ ├── override.js │ │ └── setAsyncInterval.js │ └── webDriverOverrides │ │ └── windowSize.js │ └── jest │ ├── WebDriverEnvironment.js │ ├── allocateWebDriver.js │ ├── mergeCoverageMap.js │ ├── runHTML.js │ └── setupToMatchImageSnapshot.js └── samples ├── context.html └── recomposition.html /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.202.1/containers/javascript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16-bullseye" 5 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node modules 16 | # RUN su node -c "npm install -g " 17 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.202.1/containers/javascript-node 3 | { 4 | "name": "Node.js", 5 | "runArgs": ["--init"], 6 | "build": { 7 | "dockerfile": "Dockerfile", 8 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 9 | // Append -bullseye or -buster to pin to an OS version. 10 | // Use -bullseye variants on local arm64/Apple Silicon. 11 | "args": { "VARIANT": "16-bullseye" } 12 | }, 13 | 14 | // Set *default* container specific settings.json values on container create. 15 | "settings": {}, 16 | 17 | // Add the IDs of extensions you want installed when the container is created. 18 | "extensions": ["dbaeumer.vscode-eslint", "compulim.compulim-vscode-closetag", "esbenp.prettier-vscode"], 19 | 20 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 21 | // "forwardPorts": [], 22 | 23 | // Use 'postCreateCommand' to run commands after the container is created. 24 | "postCreateCommand": ["npm install", "npm run bootstrap", "npm run build"], 25 | 26 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 27 | "remoteUser": "node", 28 | 29 | "features": { 30 | "docker-in-docker": "latest" 31 | }, 32 | 33 | "containerEnv": { 34 | "SKIP_PREFLIGHT_CHECK": "true" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/__tests__ 3 | !/Dockerfile 4 | !/packages/component/dist/* 5 | -------------------------------------------------------------------------------- /.eslintrc.react.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | 4 | extends: 5 | - ./.eslintrc.yml 6 | - plugin:react/recommended 7 | - plugin:react-hooks/recommended 8 | 9 | parserOptions: 10 | ecmaFeatures: 11 | jsx: true 12 | 13 | plugins: 14 | - react 15 | - react-hooks 16 | 17 | rules: 18 | react/button-has-type: error 19 | react/default-props-match-prop-types: error 20 | react/destructuring-assignment: error 21 | react/display-name: off 22 | react/forbid-component-props: 23 | - error 24 | - forbid: 25 | - style 26 | react/forbid-dom-props: 27 | - error 28 | - forbid: 29 | - id 30 | 31 | # Need to set "children" prop types to "any" 32 | # react/forbid-prop-types: error 33 | 34 | react/no-access-state-in-setstate: error 35 | react/no-array-index-key: error 36 | react/no-danger: error 37 | react/no-did-mount-set-state: error 38 | react/no-did-update-set-state: error 39 | react/no-redundant-should-component-update: error 40 | react/no-typos: error 41 | react/no-this-in-sfc: error 42 | react/no-unescaped-entities: error 43 | react/no-unsafe: error 44 | react/no-unused-prop-types: error 45 | react/no-unused-state: error 46 | react/no-will-update-set-state: error 47 | react/prefer-es6-class: error 48 | react/prefer-read-only-props: error 49 | react/require-default-props: error 50 | react/self-closing-comp: error 51 | react/sort-prop-types: 52 | - error 53 | - ignoreCase: true 54 | react/state-in-constructor: error 55 | react/static-property-placement: 56 | - error 57 | - property assignment 58 | react/style-prop-object: error 59 | react/void-dom-elements-no-children: error 60 | react/jsx-boolean-value: 61 | - error 62 | - always 63 | react/jsx-closing-bracket-location: 64 | - error 65 | - tag-aligned 66 | react/jsx-closing-tag-location: error 67 | react/jsx-equals-spacing: 68 | - error 69 | - never 70 | react/jsx-first-prop-new-line: 71 | - error 72 | - multiline-multiprop 73 | react/jsx-handler-names: error 74 | react/jsx-indent: 75 | - error 76 | - 2 77 | react/jsx-indent-props: 78 | - error 79 | - 2 80 | react/jsx-max-props-per-line: 81 | - error 82 | - maximum: 1 83 | when: multiline 84 | react/jsx-no-bind: error 85 | react/jsx-no-literals: error 86 | 87 | # Conflicts with prettier 88 | # react/jsx-one-expression-per-line: 89 | # - error 90 | # - allow: literal 91 | 92 | react/jsx-fragments: 93 | - error 94 | - element 95 | react/jsx-pascal-case: error 96 | react/jsx-props-no-multi-spaces: error 97 | react/jsx-sort-default-props: 98 | - error 99 | - ignoreCase: true 100 | react/jsx-sort-props: 101 | - error 102 | - ignoreCase: true 103 | react/jsx-tag-spacing: 104 | - error 105 | - afterOpening: never 106 | beforeClosing: never 107 | beforeSelfClosing: always 108 | closingSlash: never 109 | 110 | # Conflict with no-extra-parens 111 | react/jsx-wrap-multilines: error 112 | 113 | react-hooks/rules-of-hooks: error 114 | react-hooks/exhaustive-deps: warn 115 | 116 | settings: 117 | react: 118 | version: detect 119 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - eslint:recommended 3 | 4 | parserOptions: 5 | ecmaVersion: 12 6 | sourceType: module 7 | 8 | env: 9 | es6: true 10 | 11 | plugins: 12 | - prettier 13 | 14 | root: true 15 | 16 | rules: 17 | # Only list rules that are not in *:recommended set 18 | # If rules are set to disable the one in *:recommended, please elaborate the reason 19 | 20 | # Group - Best Practices 21 | no-async-promise-executor: error 22 | no-await-in-loop: error 23 | no-console: 24 | - error 25 | - allow: 26 | - error 27 | - warn 28 | # no-extra-parens: error # conflicts with prettier 29 | no-misleading-character-class: error 30 | no-template-curly-in-string: error 31 | require-atomic-updates: error 32 | accessor-pairs: error 33 | block-scoped-var: error 34 | class-methods-use-this: 35 | - error 36 | - exceptMethods: 37 | - render 38 | complexity: error 39 | curly: error 40 | default-case: error 41 | dot-notation: error 42 | eqeqeq: error 43 | max-classes-per-file: 44 | - error 45 | - 2 46 | no-alert: error 47 | no-caller: error 48 | no-div-regex: error 49 | no-else-return: error 50 | no-empty-function: error 51 | no-eq-null: error 52 | no-eval: error 53 | no-extend-native: error 54 | no-extra-bind: error 55 | no-extra-label: error 56 | no-implicit-globals: error 57 | no-implied-eval: error 58 | no-invalid-this: error 59 | no-iterator: error 60 | no-labels: error 61 | no-lone-blocks: error 62 | no-magic-numbers: 63 | - error 64 | - ignore: 65 | - 0 66 | - 1 67 | no-multi-spaces: 68 | - error 69 | - ignoreEOLComments: true 70 | no-multi-str: error 71 | no-new: error 72 | no-new-func: error 73 | no-new-wrappers: error 74 | no-octal-escape: error 75 | no-proto: error 76 | no-return-assign: error 77 | no-return-await: error 78 | no-script-url: error 79 | no-self-compare: error 80 | no-sequences: error 81 | no-throw-literal: error 82 | no-unmodified-loop-condition: error 83 | no-unused-expressions: 84 | - error 85 | - allowShortCircuit: true 86 | allowTernary: true 87 | no-useless-call: error 88 | no-useless-catch: error 89 | no-useless-concat: error 90 | no-useless-return: error 91 | no-void: error 92 | no-with: error 93 | prefer-promise-reject-errors: error 94 | radix: error 95 | require-await: error 96 | require-unicode-regexp: error 97 | wrap-iife: error 98 | yoda: error 99 | 100 | # Group - Variables 101 | no-label-var: error 102 | 103 | # Too easy for false positive 104 | # no-shadow: 105 | # - error 106 | # - builtinGlobals: true 107 | # hoist: all 108 | 109 | no-shadow-restricted-names: error 110 | no-undef-init: error 111 | 112 | # "undefined" is commonly used for defaults 113 | # no-undefined: off 114 | 115 | no-unused-vars: 116 | - error 117 | - argsIgnorePattern: ^_$ 118 | varsIgnorePattern: ^_ 119 | 120 | no-use-before-define: error 121 | 122 | # Group - ECMAScript 6 123 | arrow-body-style: 124 | - error 125 | - as-needed 126 | 127 | arrow-parens: 128 | - error 129 | - as-needed 130 | 131 | arrow-spacing: 132 | - error 133 | - after: true 134 | before: true 135 | 136 | # Conflicts with Prettier 137 | # generator-star-spacing: 138 | # - error 139 | # - after 140 | 141 | # Will produce shorter code if "off" 142 | # no-confusing-arrow: 143 | # - error 144 | # - allowParens: true # This will conflict with no-extra-parens 145 | 146 | no-duplicate-imports: error 147 | no-useless-computed-key: error 148 | no-useless-constructor: error 149 | no-useless-rename: error 150 | no-var: error 151 | object-shorthand: error 152 | prefer-arrow-callback: error 153 | prefer-const: error 154 | prefer-destructuring: error 155 | prefer-rest-params: error 156 | prefer-spread: error 157 | 158 | # This will force, a + '', into, `${ a }`, which increase code length 159 | # prefer-template: error 160 | 161 | rest-spread-spacing: 162 | - error 163 | - never 164 | 165 | # Cannot group global or local imports and sort in differently 166 | sort-imports: 167 | - error 168 | - allowSeparatedGroups: true 169 | ignoreCase: true 170 | memberSyntaxSortOrder: 171 | - none 172 | - all 173 | - single 174 | - multiple 175 | 176 | template-curly-spacing: 177 | - error 178 | - never 179 | 180 | yield-star-spacing: 181 | - error 182 | - after 183 | 184 | # plugin:prettier 185 | prettier/prettier: error 186 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | > Please copy and paste new entries from `CHANGELOG.md` here. 4 | 5 | ## Specific changes 6 | 7 | > Please list each individual specific change in this pull request. 8 | 9 | - -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Continuous integration and deployment 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | tags: 11 | - '*' 12 | 13 | pull_request: 14 | branches: 15 | - main 16 | 17 | jobs: 18 | # "build" job will build artifacts for production. 19 | build: 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | matrix: 24 | node-version: [16.x] 25 | 26 | steps: 27 | - name: Checking out for ${{ github.ref }} 28 | uses: actions/checkout@v2 29 | 30 | - name: Using Node.js ${{ matrix.node-version }} 31 | uses: actions/setup-node@v1 32 | with: 33 | node-version: ${{ matrix.node-version }} 34 | 35 | - name: Running npx version-from-git --no-git-tag-version 36 | if: ${{ startsWith(github.ref, 'refs/heads/') }} 37 | run: npx version-from-git --no-git-tag-version 38 | 39 | - name: Installing dependencies 40 | run: npm ci 41 | 42 | - name: Bootstrapping packages 43 | run: npm run bootstrap 44 | 45 | - name: Propagating versions 46 | run: | 47 | node_modules/.bin/lerna version --force-publish --no-git-tag-version --no-push --yes `cat package.json | jq -r .version` 48 | 49 | - name: Building for production 50 | env: 51 | NODE_ENV: production 52 | SKIP_PREFLIGHT_CHECK: 'true' 53 | run: npm run build --if-present 54 | 55 | - name: Copying documents 56 | run: | 57 | cp CHANGELOG.md packages/component 58 | cp LICENSE packages/component 59 | cp README.md packages/component 60 | 61 | - name: Running npm pack 62 | run: | 63 | cd packages/component 64 | npm pack 65 | 66 | - name: Uploading npm-tarball 67 | uses: actions/upload-artifact@v2 68 | with: 69 | name: npm-tarball 70 | path: 'packages/component/*.tgz' 71 | 72 | - name: Uploading gh-pages 73 | uses: actions/upload-artifact@v2 74 | with: 75 | name: gh-pages 76 | path: 'packages/playground/build/**/*' 77 | 78 | # "test" job will only run when not deploying, will build for instrumentation. 79 | test: 80 | if: ${{ !startsWith(github.ref, 'refs/heads/') && !startsWith(github.ref, 'refs/tags/') }} 81 | runs-on: ubuntu-latest 82 | 83 | strategy: 84 | matrix: 85 | node-version: [14.x, 16.x] 86 | 87 | steps: 88 | - name: Checking out for ${{ github.ref }} 89 | uses: actions/checkout@v2 90 | 91 | - name: Using Node.js ${{ matrix.node-version }} 92 | uses: actions/setup-node@v1 93 | with: 94 | node-version: ${{ matrix.node-version }} 95 | 96 | - name: Running npx version-from-git --no-git-tag-version 97 | if: ${{ startsWith(github.ref, 'refs/heads/') }} 98 | run: npx version-from-git --no-git-tag-version 99 | 100 | - name: Installing dependencies 101 | run: npm ci 102 | 103 | - name: Bootstrapping packages 104 | run: npm run bootstrap 105 | 106 | - name: Propagating versions 107 | run: node_modules/.bin/lerna version --force-publish --no-git-tag-version --no-push --yes `cat package.json | jq -r .version` 108 | 109 | - name: Running static code analysis 110 | run: | 111 | cd packages/component 112 | npm run precommit src/ 113 | 114 | - name: Building for instrumentation 115 | env: 116 | NODE_ENV: test 117 | SKIP_PREFLIGHT_CHECK: 'true' 118 | run: npm run build --if-present 119 | 120 | - name: Starting Docker Compose 121 | run: npm run docker:up -- --detach 122 | 123 | - name: Testing 124 | run: npm test -- --coverage 125 | 126 | - if: always() 127 | name: Stopping Docker Compose 128 | run: npm run docker:down 129 | 130 | # "public" job will only run when merging a commit or tag. 131 | # It does not depends on "test" because we assume it already passed pull request status checks and "test" can be unreliable at times. 132 | publish: 133 | needs: 134 | - build 135 | runs-on: ubuntu-latest 136 | if: ${{ startsWith(github.ref, 'refs/heads/') || startsWith(github.ref, 'refs/tags/') }} 137 | 138 | steps: 139 | - name: Downloading npm-tarball 140 | uses: actions/download-artifact@v2 141 | with: 142 | name: npm-tarball 143 | 144 | - name: Downloading gh-pages 145 | uses: actions/download-artifact@v2 146 | with: 147 | name: gh-pages 148 | path: gh-pages/ 149 | 150 | - name: Reading package.json 151 | id: read-package-json 152 | run: | 153 | echo "::set-output name=name::$(tar xOf *.tgz package/package.json | jq -r '.name')" 154 | echo "::set-output name=version::$(tar xOf *.tgz package/package.json | jq -r '.version')" 155 | echo "::set-output name=tarball::$(ls *.tgz)" 156 | echo "::set-output name=date::$(date +%Y-%m-%d)" 157 | 158 | - name: Publishing ${{ steps.read-package-json.outputs.name }}@${{ steps.read-package-json.outputs.version }} 159 | run: | 160 | npm config set //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }} 161 | npm publish *.tgz --tag main 162 | 163 | - name: Tagging dist-tag ${{ steps.read-package-json.outputs.name }}@${{ steps.read-package-json.outputs.version }} latest 164 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 165 | run: | 166 | npm dist-tag add ${{ steps.read-package-json.outputs.name }}@${{ steps.read-package-json.outputs.version }} latest 167 | 168 | - name: Drafting a new release 169 | uses: actions/create-release@v1 170 | id: create-release 171 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 172 | env: 173 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 174 | with: 175 | tag_name: ${{ github.ref }} 176 | release_name: '[${{ steps.read-package-json.outputs.version }}] - ${{ steps.read-package-json.outputs.date }}' 177 | draft: true 178 | 179 | - name: Uploading tarball to release 180 | uses: actions/upload-release-asset@v1 181 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 182 | env: 183 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 184 | with: 185 | upload_url: ${{ steps.create-release.outputs.upload_url }} 186 | asset_path: ./${{ steps.read-package-json.outputs.tarball }} 187 | asset_name: ${{ steps.read-package-json.outputs.tarball }} 188 | asset_content_type: application/octet-stream 189 | 190 | - name: Deploying to GitHub Pages 191 | uses: peaceiris/actions-gh-pages@v3 192 | with: 193 | github_token: ${{ secrets.GITHUB_TOKEN }} 194 | publish_dir: ./gh-pages/ 195 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /chromedriver* 3 | /coverage 4 | /lerna-debug.log 5 | /lib 6 | /node_modules 7 | -------------------------------------------------------------------------------- /.husky/.gitattributes: -------------------------------------------------------------------------------- 1 | pre-commit eol=lf 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run precommit 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | EXPOSE 80 4 | RUN npm install serve -g 5 | WORKDIR /var/web/ 6 | ENTRYPOINT ["npx", "--no-install", "serve", "-c", "serve.json", "-p", "80", "/var/web/"] 7 | 8 | RUN echo {}>/var/web/package.json 9 | 10 | ADD __tests__/*.html /var/web/ 11 | ADD __tests__/assets/ /var/web/assets/ 12 | ADD __tests__/favicon.ico /var/web/ 13 | ADD __tests__/serve.json /var/web/ 14 | ADD packages/component/dist/ /var/web/ 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 William Wong 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 | -------------------------------------------------------------------------------- /TESTS.md: -------------------------------------------------------------------------------- 1 | # Manual tests 2 | 3 | ## Quirks 4 | 5 | These are tests for regressions. 6 | 7 | Assumptions: 8 | 9 | - The container size is `500px` 10 | - Each element size is default at `100px` (unless specified) 11 | - The container contains 10 elements and is sticky 12 | 13 | ### Add elements quickly 14 | 15 | > Press and hold 1 in playground for a few seconds. 16 | 17 | - [ ] Add 20+ elements very quickly 18 | - [ ] Test it again on Firefox 19 | 20 | Expect: 21 | 22 | - It should not lose stickiness 23 | - During elements add, it should not lose stickiness for a split second 24 | - In playground, it should not turn pink at any moments 25 | 26 | ### Scroller 27 | 28 | - [ ] Set a scroller of `100px` 29 | - [ ] Add 1 element of `50px` 30 | - [ ] Add another element of `200px` very quickly after the previous one 31 | - Preferably, use `requestAnimationFrame` 32 | 33 | Expect: 34 | 35 | - It should stop at 100px 36 | 37 | ### Resizing container 38 | 39 | > Press 4 1 5 1 1 in the playground. 40 | 41 | - [ ] Change the container size to `200px` 42 | - [ ] Add an element 43 | - [ ] Change the container size back to `500px` 44 | - [ ] Add 2 elements 45 | 46 | Expect: 47 | 48 | - It should not lose stickiness during the whole test 49 | 50 | ### Focusing to an interactive element 51 | 52 | - [ ] Add 10 elements 53 | - [ ] Scroll to top (losing stickiness) 54 | - [ ] Add a ` 40 | ) 41 | ); 42 | }; 43 | 44 | AutoHideFollowButton.defaultProps = { 45 | children: undefined, 46 | className: '' 47 | }; 48 | 49 | AutoHideFollowButton.propTypes = { 50 | children: PropTypes.any, 51 | className: PropTypes.string 52 | }; 53 | 54 | export default AutoHideFollowButton; 55 | -------------------------------------------------------------------------------- /packages/component/src/ScrollToBottom/FunctionContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const context = React.createContext({ 4 | scrollTo: () => 0, 5 | scrollToBottom: () => 0, 6 | scrollToEnd: () => 0, 7 | scrollToStart: () => 0, 8 | scrollToTop: () => 0 9 | }); 10 | 11 | context.displayName = 'ScrollToBottomFunctionContext'; 12 | 13 | export default context; 14 | -------------------------------------------------------------------------------- /packages/component/src/ScrollToBottom/InternalContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const context = React.createContext({ 4 | offsetHeight: 0, 5 | scrollHeight: 0, 6 | setTarget: () => 0, 7 | styleToClassName: () => '' 8 | }); 9 | 10 | context.displayName = 'ScrollToBottomInternalContext'; 11 | 12 | export default context; 13 | -------------------------------------------------------------------------------- /packages/component/src/ScrollToBottom/Panel.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import PropTypes from 'prop-types'; 3 | import React, { useContext } from 'react'; 4 | 5 | import InternalContext from './InternalContext'; 6 | import useStyleToClassName from '../hooks/internal/useStyleToClassName'; 7 | 8 | const ROOT_STYLE = { 9 | height: '100%', 10 | overflowY: 'auto', 11 | width: '100%' 12 | }; 13 | 14 | const Panel = ({ children, className }) => { 15 | const { setTarget } = useContext(InternalContext); 16 | const rootCSS = useStyleToClassName()(ROOT_STYLE); 17 | 18 | return ( 19 |
20 | {children} 21 |
22 | ); 23 | }; 24 | 25 | Panel.defaultProps = { 26 | children: undefined, 27 | className: undefined 28 | }; 29 | 30 | Panel.propTypes = { 31 | children: PropTypes.any, 32 | className: PropTypes.string 33 | }; 34 | 35 | export default Panel; 36 | -------------------------------------------------------------------------------- /packages/component/src/ScrollToBottom/State1Context.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const context = React.createContext({ 4 | atBottom: true, 5 | atEnd: true, 6 | atStart: false, 7 | atTop: true, 8 | mode: 'bottom' 9 | }); 10 | 11 | context.displayName = 'ScrollToBottomState1Context'; 12 | 13 | export default context; 14 | -------------------------------------------------------------------------------- /packages/component/src/ScrollToBottom/State2Context.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const context = React.createContext({ 4 | animating: false, 5 | animatingToEnd: false, 6 | sticky: true 7 | }); 8 | 9 | context.displayName = 'ScrollToBottomState2Context'; 10 | 11 | export default context; 12 | -------------------------------------------------------------------------------- /packages/component/src/ScrollToBottom/StateContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const context = React.createContext({ 4 | animating: false, 5 | animatingToEnd: false, 6 | atBottom: true, 7 | atEnd: true, 8 | atStart: false, 9 | atTop: true, 10 | mode: 'bottom', 11 | sticky: true 12 | }); 13 | 14 | context.displayName = 'ScrollToBottomStateContext'; 15 | 16 | export default context; 17 | -------------------------------------------------------------------------------- /packages/component/src/SpineTo.js: -------------------------------------------------------------------------------- 1 | /* eslint no-magic-numbers: ["error", { "ignore": [0, 1, 1.5, 5] }] */ 2 | 3 | import PropTypes from 'prop-types'; 4 | import { useCallback, useLayoutEffect, useRef } from 'react'; 5 | 6 | function squareStepper(current, to) { 7 | const sign = Math.sign(to - current); 8 | const step = Math.sqrt(Math.abs(to - current)); 9 | const next = current + step * sign; 10 | 11 | if (sign > 0) { 12 | return Math.min(to, next); 13 | } 14 | 15 | return Math.max(to, next); 16 | } 17 | 18 | function step(from, to, stepper, index) { 19 | let next = from; 20 | 21 | for (let i = 0; i < index; i++) { 22 | next = stepper(next, to); 23 | } 24 | 25 | return next; 26 | } 27 | 28 | const SpineTo = ({ name, onEnd, target, value }) => { 29 | const animator = useRef(); 30 | 31 | const animate = useCallback( 32 | (name, from, to, index, start = Date.now()) => { 33 | if (to === '100%' || typeof to === 'number') { 34 | cancelAnimationFrame(animator.current); 35 | 36 | animator.current = requestAnimationFrame(() => { 37 | if (target) { 38 | const toNumber = to === '100%' ? target.scrollHeight - target.offsetHeight : to; 39 | let nextValue = step(from, toNumber, squareStepper, (Date.now() - start) / 5); 40 | 41 | if (Math.abs(toNumber - nextValue) < 1.5) { 42 | nextValue = toNumber; 43 | } 44 | 45 | target[name] = nextValue; 46 | 47 | if (toNumber === nextValue) { 48 | onEnd && onEnd(true); 49 | } else { 50 | animate(name, from, to, index + 1, start); 51 | } 52 | } 53 | }); 54 | } 55 | }, 56 | [animator, onEnd, target] 57 | ); 58 | 59 | const handleCancelAnimation = useCallback(() => { 60 | cancelAnimationFrame(animator.current); 61 | onEnd && onEnd(false); 62 | }, [onEnd]); 63 | 64 | useLayoutEffect(() => { 65 | animate(name, target[name], value, 1); 66 | 67 | if (target) { 68 | target.addEventListener('pointerdown', handleCancelAnimation, { passive: true }); 69 | target.addEventListener('wheel', handleCancelAnimation, { passive: true }); 70 | 71 | return () => { 72 | target.removeEventListener('pointerdown', handleCancelAnimation); 73 | target.removeEventListener('wheel', handleCancelAnimation); 74 | cancelAnimationFrame(animator.current); 75 | }; 76 | } 77 | 78 | return () => cancelAnimationFrame(animator.current); 79 | }, [animate, animator, handleCancelAnimation, name, target, value]); 80 | 81 | return false; 82 | }; 83 | 84 | SpineTo.propTypes = { 85 | name: PropTypes.string.isRequired, 86 | onEnd: PropTypes.func, 87 | target: PropTypes.any.isRequired, 88 | value: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf(['100%'])]).isRequired 89 | }; 90 | 91 | export default SpineTo; 92 | -------------------------------------------------------------------------------- /packages/component/src/addVersionToMetaTag.js: -------------------------------------------------------------------------------- 1 | /* global global:readonly, process:readonly */ 2 | /* eslint no-empty: ["error", { "allowEmptyCatch": true }] */ 3 | 4 | function setMetaTag(name, content) { 5 | try { 6 | const { document } = global; 7 | 8 | if (typeof document !== 'undefined' && document.createElement && document.head && document.head.appendChild) { 9 | const meta = document.querySelector(`html meta[name="${encodeURI(name)}"]`) || document.createElement('meta'); 10 | 11 | meta.setAttribute('name', name); 12 | meta.setAttribute('content', content); 13 | 14 | document.head.appendChild(meta); 15 | } 16 | } catch (err) {} 17 | } 18 | 19 | export default function addVersionToMetaTag() { 20 | setMetaTag('react-scroll-to-bottom:version', process.env.npm_package_version); 21 | } 22 | -------------------------------------------------------------------------------- /packages/component/src/browser.js: -------------------------------------------------------------------------------- 1 | import * as ReactScrollToBottom from './index'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | 5 | // TODO: This is for testing only. Don't use it in production environment unless we have isomorphic React. 6 | window.React = React; 7 | window.ReactDOM = ReactDOM; 8 | 9 | window.ReactScrollToBottom = ReactScrollToBottom; 10 | -------------------------------------------------------------------------------- /packages/component/src/createCSSKey.js: -------------------------------------------------------------------------------- 1 | /* eslint no-magic-numbers: "off" */ 2 | 3 | import random from 'math-random'; 4 | 5 | export default function useCSSKey() { 6 | return random() 7 | .toString(26) 8 | .substr(2, 5) 9 | .replace(/\d/gu, value => String.fromCharCode(value.charCodeAt(0) + 65)); 10 | } 11 | -------------------------------------------------------------------------------- /packages/component/src/debounce.js: -------------------------------------------------------------------------------- 1 | export default function (fn, ms) { 2 | if (!ms) { 3 | return fn; 4 | } 5 | 6 | let last = 0; 7 | let timeout = null; 8 | 9 | return (...args) => { 10 | const now = Date.now(); 11 | 12 | if (now - last > ms) { 13 | fn(...args); 14 | last = now; 15 | } else { 16 | clearTimeout(timeout); 17 | 18 | timeout = setTimeout(() => { 19 | fn(...args); 20 | last = Date.now(); 21 | }, Math.max(0, ms - now + last)); 22 | } 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /packages/component/src/hooks/internal/useEmotion.js: -------------------------------------------------------------------------------- 1 | import createEmotion from '@emotion/css/create-instance'; 2 | import { useEffect, useMemo } from 'react'; 3 | 4 | import createCSSKey from '../../createCSSKey'; 5 | 6 | const sharedEmotionInstances = []; 7 | 8 | export default function useEmotion(nonce, container) { 9 | const emotion = useMemo(() => { 10 | const sharedEmotion = sharedEmotionInstances.find( 11 | ({ sheet }) => sheet.nonce === nonce && sheet.container === container 12 | ); 13 | const emotion = 14 | sharedEmotion ?? createEmotion({ container, key: `react-scroll-to-bottom--css-${createCSSKey()}`, nonce }); 15 | 16 | sharedEmotionInstances.push(emotion); 17 | 18 | return emotion; 19 | }, [container, nonce]); 20 | 21 | useEffect( 22 | () => 23 | emotion?.sheet && 24 | (() => { 25 | const index = sharedEmotionInstances.lastIndexOf(emotion); 26 | 27 | // Reduce ref count for the specific emotion instance. 28 | ~index && sharedEmotionInstances.splice(index, 1); 29 | 30 | if (!sharedEmotionInstances.includes(emotion)) { 31 | // No more hooks use this emotion object, we can clean up the container for stuff we added. 32 | for (const child of emotion.sheet.tags) { 33 | child.remove(); 34 | } 35 | } 36 | }), 37 | [emotion] 38 | ); 39 | 40 | return emotion; 41 | } 42 | -------------------------------------------------------------------------------- /packages/component/src/hooks/internal/useFunctionContext.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import FunctionContext from '../../ScrollToBottom/FunctionContext'; 4 | 5 | export default function useFunctionContext() { 6 | return useContext(FunctionContext); 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/internal/useInternalContext.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import InternalContext from '../../ScrollToBottom/InternalContext'; 4 | 5 | export default function useInternalContext() { 6 | return useContext(InternalContext); 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/internal/useStateContext.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import State1Context from '../../ScrollToBottom/State1Context'; 4 | import State2Context from '../../ScrollToBottom/State2Context'; 5 | import StateContext from '../../ScrollToBottom/StateContext'; 6 | 7 | const stateContexts = [StateContext, State1Context, State2Context]; 8 | 9 | export default function useStateContext(tier) { 10 | return useContext(stateContexts[tier] || stateContexts[0]); 11 | } 12 | -------------------------------------------------------------------------------- /packages/component/src/hooks/internal/useStateRef.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from 'react'; 2 | 3 | export default function useStateRef(initialState) { 4 | const [state, setState] = useState(initialState); 5 | const ref = useRef(); 6 | const setValue = useCallback( 7 | nextValue => { 8 | if (typeof nextValue === 'function') { 9 | setValue(state => { 10 | nextValue = nextValue(state); 11 | 12 | ref.current = nextValue; 13 | 14 | return nextValue; 15 | }); 16 | } else { 17 | ref.current = nextValue; 18 | 19 | setValue(nextValue); 20 | } 21 | }, 22 | [ref] 23 | ); 24 | 25 | ref.current = state; 26 | 27 | return [state, setState, ref]; 28 | } 29 | -------------------------------------------------------------------------------- /packages/component/src/hooks/internal/useStyleToClassName.js: -------------------------------------------------------------------------------- 1 | import useInternalContext from './useInternalContext'; 2 | 3 | export default function useStyleToClassName() { 4 | const { styleToClassName } = useInternalContext(); 5 | 6 | return styleToClassName; 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useAnimating.js: -------------------------------------------------------------------------------- 1 | /* eslint no-magic-numbers: ["error", { "ignore": [2] }] */ 2 | 3 | import useStateContext from './internal/useStateContext'; 4 | 5 | export default function useAnimating() { 6 | const { animating } = useStateContext(2); 7 | 8 | return [animating]; 9 | } 10 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useAnimatingToEnd.js: -------------------------------------------------------------------------------- 1 | /* eslint no-magic-numbers: ["error", { "ignore": [2] }] */ 2 | 3 | import useStateContext from './internal/useStateContext'; 4 | 5 | export default function useAnimatingToEnd() { 6 | const { animatingToEnd } = useStateContext(2); 7 | 8 | return [animatingToEnd]; 9 | } 10 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useAtBottom.js: -------------------------------------------------------------------------------- 1 | import useStateContext from './internal/useStateContext'; 2 | 3 | export default function useAtBottom() { 4 | const { atBottom } = useStateContext(1); 5 | 6 | return [atBottom]; 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useAtEnd.js: -------------------------------------------------------------------------------- 1 | import useStateContext from './internal/useStateContext'; 2 | 3 | export default function useAtEnd() { 4 | const { atEnd } = useStateContext(1); 5 | 6 | return [atEnd]; 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useAtStart.js: -------------------------------------------------------------------------------- 1 | import useStateContext from './internal/useStateContext'; 2 | 3 | export default function useAtStart() { 4 | const { atStart } = useStateContext(1); 5 | 6 | return [atStart]; 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useAtTop.js: -------------------------------------------------------------------------------- 1 | import useStateContext from './internal/useStateContext'; 2 | 3 | export default function useAtTop() { 4 | const { atTop } = useStateContext(1); 5 | 6 | return [atTop]; 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useMode.js: -------------------------------------------------------------------------------- 1 | import useStateContext from './internal/useStateContext'; 2 | 3 | export default function useMode() { 4 | const { mode } = useStateContext(1); 5 | 6 | return [mode]; 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useObserveScrollPosition.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import useInternalContext from './internal/useInternalContext'; 4 | 5 | export default function useObserveScrollPosition(observer, deps = []) { 6 | if (observer && typeof observer !== 'function') { 7 | console.error('react-scroll-to-bottom: First argument passed to "useObserveScrollPosition" must be a function.'); 8 | } else if (!Array.isArray(deps)) { 9 | console.error( 10 | 'react-scroll-to-bottom: Second argument passed to "useObserveScrollPosition" must be an array if specified.' 11 | ); 12 | } 13 | 14 | const { observeScrollPosition } = useInternalContext(); 15 | 16 | /* eslint-disable-next-line react-hooks/exhaustive-deps */ 17 | useEffect(() => observer && observeScrollPosition(observer), [...deps, !observer, observeScrollPosition]); 18 | } 19 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useScrollTo.js: -------------------------------------------------------------------------------- 1 | import useFunctionContext from './internal/useFunctionContext'; 2 | 3 | export default function useScrollTo() { 4 | const { scrollTo } = useFunctionContext(); 5 | 6 | return scrollTo; 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useScrollToBottom.js: -------------------------------------------------------------------------------- 1 | import useFunctionContext from './internal/useFunctionContext'; 2 | 3 | export default function useScrollToBottom() { 4 | const { scrollToBottom } = useFunctionContext(); 5 | 6 | return scrollToBottom; 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useScrollToEnd.js: -------------------------------------------------------------------------------- 1 | import useFunctionContext from './internal/useFunctionContext'; 2 | 3 | export default function useScrollToEnd() { 4 | const { scrollToEnd } = useFunctionContext(); 5 | 6 | return scrollToEnd; 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useScrollToStart.js: -------------------------------------------------------------------------------- 1 | import useFunctionContext from './internal/useFunctionContext'; 2 | 3 | export default function useScrollToStart() { 4 | const { scrollToStart } = useFunctionContext(); 5 | 6 | return scrollToStart; 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useScrollToTop.js: -------------------------------------------------------------------------------- 1 | import useFunctionContext from './internal/useFunctionContext'; 2 | 3 | export default function useScrollToTop() { 4 | const { scrollToTop } = useFunctionContext(); 5 | 6 | return scrollToTop; 7 | } 8 | -------------------------------------------------------------------------------- /packages/component/src/hooks/useSticky.js: -------------------------------------------------------------------------------- 1 | /* eslint no-magic-numbers: ["error", { "ignore": [2] }] */ 2 | 3 | import useStateContext from './internal/useStateContext'; 4 | 5 | export default function useSticky() { 6 | const { sticky } = useStateContext(2); 7 | 8 | return [sticky]; 9 | } 10 | -------------------------------------------------------------------------------- /packages/component/src/index.js: -------------------------------------------------------------------------------- 1 | import addVersionToMetaTag from './addVersionToMetaTag'; 2 | 3 | import AutoHideFollowButton from './ScrollToBottom/AutoHideFollowButton'; 4 | import BasicScrollToBottom from './BasicScrollToBottom'; 5 | import Composer from './ScrollToBottom/Composer'; 6 | import FunctionContext from './ScrollToBottom/FunctionContext'; 7 | import Panel from './ScrollToBottom/Panel'; 8 | import StateContext from './ScrollToBottom/StateContext'; 9 | 10 | import useAnimating from './hooks/useAnimating'; 11 | import useAnimatingToEnd from './hooks/useAnimatingToEnd'; 12 | import useAtBottom from './hooks/useAtBottom'; 13 | import useAtEnd from './hooks/useAtEnd'; 14 | import useAtStart from './hooks/useAtStart'; 15 | import useAtTop from './hooks/useAtTop'; 16 | import useMode from './hooks/useMode'; 17 | import useObserveScrollPosition from './hooks/useObserveScrollPosition'; 18 | import useScrollTo from './hooks/useScrollTo'; 19 | import useScrollToBottom from './hooks/useScrollToBottom'; 20 | import useScrollToEnd from './hooks/useScrollToEnd'; 21 | import useScrollToStart from './hooks/useScrollToStart'; 22 | import useScrollToTop from './hooks/useScrollToTop'; 23 | import useSticky from './hooks/useSticky'; 24 | 25 | export default BasicScrollToBottom; 26 | 27 | export { 28 | AutoHideFollowButton, 29 | Composer, 30 | FunctionContext, 31 | Panel, 32 | StateContext, 33 | useAnimating, 34 | useAnimatingToEnd, 35 | useAtBottom, 36 | useAtEnd, 37 | useAtStart, 38 | useAtTop, 39 | useMode, 40 | useObserveScrollPosition, 41 | useScrollTo, 42 | useScrollToBottom, 43 | useScrollToEnd, 44 | useScrollToStart, 45 | useScrollToTop, 46 | useSticky 47 | }; 48 | 49 | addVersionToMetaTag(); 50 | -------------------------------------------------------------------------------- /packages/component/src/utils/debug.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: ["off"] */ 2 | 3 | import styleConsole from './styleConsole'; 4 | 5 | function format(category, arg0, ...args) { 6 | return [`%c${category}%c ${arg0}`, ...styleConsole('green', 'white'), ...args]; 7 | } 8 | 9 | export default function debug(category, { force = false } = {}) { 10 | if (!force) { 11 | return () => 0; 12 | } 13 | 14 | return (...args) => { 15 | if (!args.length) { 16 | return; 17 | } 18 | 19 | const [arg0] = args; 20 | 21 | if (typeof arg0 === 'function') { 22 | args = arg0(); 23 | } 24 | 25 | const lines = Array.isArray(args[0]) ? args : [args]; 26 | const oneLiner = lines.length === 1; 27 | 28 | lines.forEach((line, index) => { 29 | if (oneLiner) { 30 | console.log(...format(category, ...line)); 31 | } else if (index) { 32 | console.log(...(Array.isArray(line) ? line : [line])); 33 | } else { 34 | console.groupCollapsed(...format(category, ...line)); 35 | } 36 | }); 37 | 38 | oneLiner || console.groupEnd(); 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /packages/component/src/utils/styleConsole.js: -------------------------------------------------------------------------------- 1 | export default function styleConsole(backgroundColor, color = 'white') { 2 | let styles = `background-color: ${backgroundColor}; border-radius: 4px; padding: 2px 4px;`; 3 | 4 | if (color) { 5 | styles += ` color: ${color};`; 6 | } 7 | 8 | return [styles, '']; 9 | } 10 | -------------------------------------------------------------------------------- /packages/playground/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /packages/playground/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /packages/playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "version": "0.1.0", 4 | "homepage": "/react-scroll-to-bottom", 5 | "private": true, 6 | "dependencies": { 7 | "@emotion/css": "^11.1.3", 8 | "@testing-library/jest-dom": "^5.14.1", 9 | "@testing-library/react": "^11.2.7", 10 | "@testing-library/user-event": "^12.8.3", 11 | "classnames": "^2.3.1", 12 | "lorem-ipsum": "^2.0.4", 13 | "react-app-polyfill": "^2.0.0", 14 | "react-interval": "^2.1.2", 15 | "react-scripts": "^4.0.3", 16 | "react-scroll-to-bottom": "^0.0.0-0", 17 | "web-vitals": "^1.1.2" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test", 23 | "eject": "react-scripts eject", 24 | "serve": "npx nodemon --exec \"npx serve -p 5000\" --watch build --watch serve.json" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/playground/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compulim/react-scroll-to-bottom/53844f5bcad22763c75a7903212b26716fd4d333/packages/playground/public/favicon.ico -------------------------------------------------------------------------------- /packages/playground/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | React App 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /packages/playground/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compulim/react-scroll-to-bottom/53844f5bcad22763c75a7903212b26716fd4d333/packages/playground/public/logo192.png -------------------------------------------------------------------------------- /packages/playground/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/compulim/react-scroll-to-bottom/53844f5bcad22763c75a7903212b26716fd4d333/packages/playground/public/logo512.png -------------------------------------------------------------------------------- /packages/playground/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-scroll-to-bottom", 3 | "name": "Sample app for react-scroll-to-bottom", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /packages/playground/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /packages/playground/serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "redirects": [ 3 | { 4 | "destination": "/react-scroll-to-bottom/", 5 | "source": "/" 6 | } 7 | ], 8 | "rewrites": [ 9 | { 10 | "destination": "build/index.html", 11 | "source": "/react-scroll-to-bottom" 12 | }, 13 | { 14 | "destination": "build/:filename", 15 | "source": "/react-scroll-to-bottom/:filename" 16 | }, 17 | { 18 | "destination": "build/static/:dirname/:filename", 19 | "source": "/react-scroll-to-bottom/static/:dirname/:filename" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /packages/playground/src/App.js: -------------------------------------------------------------------------------- 1 | /* eslint no-magic-numbers: "off" */ 2 | 3 | import { LoremIpsum, loremIpsum } from 'lorem-ipsum'; 4 | import React, { useCallback, useEffect, useMemo, useState } from 'react'; 5 | import ReactScrollToBottom, { StateContext } from 'react-scroll-to-bottom'; 6 | 7 | import classNames from 'classnames'; 8 | import createEmotion from '@emotion/css/create-instance'; 9 | import Interval from 'react-interval'; 10 | 11 | import CommandBar from './CommandBar'; 12 | import StatusBar from './StatusBar'; 13 | 14 | const FADE_IN_ANIMATION_KEYFRAMES = { 15 | '0%': { opacity: 0.2 }, 16 | '100%': { opacity: 1 } 17 | }; 18 | 19 | const ROOT_STYLE = { 20 | '& > ul.button-bar': { 21 | display: 'flex', 22 | listStyleType: 'none', 23 | margin: 0, 24 | padding: 0, 25 | 26 | '& > li:not(:last-child)': { 27 | marginRight: 10 28 | } 29 | }, 30 | 31 | '& > .panes': { 32 | display: 'flex', 33 | 34 | '& > *': { 35 | flex: 1 36 | }, 37 | 38 | '& > *:not(:last-child)': { 39 | marginRight: 10 40 | } 41 | }, 42 | 43 | '& > .version': { 44 | bottom: 10, 45 | position: 'absolute' 46 | } 47 | }; 48 | 49 | const CONTAINER_STYLE = { 50 | borderColor: 'Black', 51 | borderStyle: 'solid', 52 | borderWidth: 1, 53 | height: 400, 54 | marginTop: 10 55 | }; 56 | 57 | const LARGE_CONTAINER_STYLE = { 58 | height: 600 59 | }; 60 | 61 | const SCROLL_VIEW_STYLE = { 62 | backgroundColor: '#EEE' 63 | }; 64 | 65 | const SCROLL_VIEW_PADDING_STYLE = { 66 | paddingLeft: 10, 67 | paddingRight: 10, 68 | 69 | '&:not(.sticky)': { 70 | backgroundColor: 'rgba(255, 0, 0, .1)' 71 | } 72 | }; 73 | 74 | const SMALL_CONTAINER_STYLE = { 75 | height: 300 76 | }; 77 | 78 | const STATUS_BAR_CSS = { 79 | bottom: 0, 80 | position: 'sticky' 81 | }; 82 | 83 | const createParagraphs = count => new Array(count).fill().map(() => loremIpsum({ units: 'paragraph' })); 84 | 85 | const App = ({ nonce }) => { 86 | const { 87 | containerCSS, 88 | largeContainerCSS, 89 | rootCSS, 90 | scrollViewCSS, 91 | scrollViewPaddingCSS, 92 | smallContainerCSS, 93 | statusBarCSS 94 | } = useMemo(() => { 95 | const { css, keyframes } = createEmotion({ key: 'playground--css-', nonce }); 96 | 97 | return { 98 | containerCSS: css(CONTAINER_STYLE), 99 | largeContainerCSS: css(LARGE_CONTAINER_STYLE), 100 | rootCSS: css(ROOT_STYLE), 101 | scrollViewCSS: css(SCROLL_VIEW_STYLE), 102 | scrollViewPaddingCSS: css({ 103 | ...SCROLL_VIEW_PADDING_STYLE, 104 | 105 | '& > p': { 106 | animation: `${keyframes(FADE_IN_ANIMATION_KEYFRAMES)} 500ms` 107 | } 108 | }), 109 | smallContainerCSS: css(SMALL_CONTAINER_STYLE), 110 | statusBarCSS: css(STATUS_BAR_CSS) 111 | }; 112 | }, [nonce]); 113 | 114 | const [containerSize, setContainerSize] = useState(''); 115 | const [intervalEnabled, setIntervalEnabled] = useState(false); 116 | const [paragraphs, setParagraphs] = useState(createParagraphs(10)); 117 | const [commandBarVisible, setCommandBarVisible] = useState(false); 118 | const [limitAutoScrollHeight, setLimitAutoScrollHeight] = useState(false); 119 | const [loadedVersion] = useState(() => 120 | document.querySelector('head meta[name="react-scroll-to-bottom:version"]').getAttribute('content') 121 | ); 122 | const [disableScrollToBottomPanel, setDisableScrollToBottomPanel] = useState(false); 123 | const [disableScrollToTopPanel, setDisableScrollToTopPanel] = useState(false); 124 | 125 | const handleDisableScrollToBottomPanelClick = useCallback( 126 | ({ target: { checked } }) => setDisableScrollToBottomPanel(checked), 127 | [setDisableScrollToBottomPanel] 128 | ); 129 | 130 | const handleDisableScrollToTopPanelClick = useCallback( 131 | ({ target: { checked } }) => setDisableScrollToTopPanel(checked), 132 | [setDisableScrollToTopPanel] 133 | ); 134 | 135 | const handleAdd = useCallback( 136 | count => setParagraphs([...paragraphs, ...createParagraphs(count)]), 137 | [paragraphs, setParagraphs] 138 | ); 139 | const handleAdd1 = useCallback(() => handleAdd(1), [handleAdd]); 140 | const handleAdd10 = useCallback(() => handleAdd(10), [handleAdd]); 141 | const handleAddButton = useCallback( 142 | () => setParagraphs([...paragraphs, 'Button: ' + loremIpsum({ units: 'words' })]), 143 | [paragraphs, setParagraphs] 144 | ); 145 | const handleAddSuccessively = useCallback(() => { 146 | const lorem = new LoremIpsum(); 147 | const nextParagraphs = [...paragraphs, lorem.generateSentences(1)]; 148 | 149 | setParagraphs(nextParagraphs); 150 | 151 | requestAnimationFrame(() => setParagraphs([...nextParagraphs, lorem.generateParagraphs(5)])); 152 | }, [paragraphs, setParagraphs]); 153 | const handleAddAndRemove = useCallback(() => { 154 | const lorem = new LoremIpsum(); 155 | const [, ...nextParagraphs] = paragraphs; 156 | 157 | nextParagraphs.push(lorem.generateParagraphs(1)); 158 | 159 | setParagraphs(nextParagraphs); 160 | }, [paragraphs, setParagraphs]); 161 | const handleClear = useCallback(() => setParagraphs([]), [setParagraphs]); 162 | const handleCommandBarVisibleChange = useCallback( 163 | ({ target: { checked } }) => setCommandBarVisible(checked), 164 | [setCommandBarVisible] 165 | ); 166 | const handleContainerSizeLarge = useCallback(() => setContainerSize('large'), [setContainerSize]); 167 | const handleContainerSizeNormal = useCallback(() => setContainerSize(''), [setContainerSize]); 168 | const handleContainerSizeSmall = useCallback(() => setContainerSize('small'), [setContainerSize]); 169 | const handleIntervalEnabledChange = useCallback( 170 | ({ target: { checked: intervalEnabled } }) => setIntervalEnabled(intervalEnabled), 171 | [setIntervalEnabled] 172 | ); 173 | const handleLimitAutoScrollHeightChange = useCallback( 174 | ({ target: { checked } }) => setLimitAutoScrollHeight(checked), 175 | [setLimitAutoScrollHeight] 176 | ); 177 | const containerClassName = useMemo( 178 | () => 179 | classNames( 180 | containerCSS + '', 181 | containerSize === 'small' ? smallContainerCSS + '' : containerSize === 'large' ? largeContainerCSS + '' : '' 182 | ), 183 | [containerCSS, containerSize, largeContainerCSS, smallContainerCSS] 184 | ); 185 | 186 | const handleKeyDown = useCallback( 187 | ({ keyCode }) => { 188 | switch (keyCode) { 189 | case 49: 190 | return handleAdd1(); 191 | case 50: 192 | return handleAdd10(); 193 | case 51: 194 | return handleClear(); 195 | case 52: 196 | return handleContainerSizeSmall(); 197 | case 53: 198 | return handleContainerSizeNormal(); 199 | case 54: 200 | return handleContainerSizeLarge(); 201 | case 55: 202 | return handleAddButton(); 203 | case 82: 204 | return window.location.reload(); // Press R key 205 | default: 206 | break; 207 | } 208 | }, 209 | [ 210 | handleAdd1, 211 | handleAdd10, 212 | handleAddButton, 213 | handleClear, 214 | handleContainerSizeLarge, 215 | handleContainerSizeNormal, 216 | handleContainerSizeSmall 217 | ] 218 | ); 219 | 220 | useEffect(() => { 221 | window.addEventListener('keydown', handleKeyDown); 222 | 223 | return () => window.removeEventListener('keydown', handleKeyDown); 224 | }, [handleKeyDown]); 225 | 226 | const scroller = useCallback(() => 100, []); 227 | 228 | return ( 229 |
230 |
    231 |
  • 232 | 233 |
  • 234 |
  • 235 | 236 |
  • 237 |
  • 238 | 239 |
  • 240 |
  • 241 | 242 |
  • 243 |
  • 244 | 245 |
  • 246 |
  • 247 | 248 |
  • 249 |
  • 250 | 251 |
  • 252 |
  • 253 | 259 |
  • 260 |
  • 261 | 264 |
  • 265 |
  • 266 | 270 |
  • 271 |
  • 272 | 276 |
  • 277 |
  • 278 | 282 |
  • 283 |
284 |
285 |
286 | {disableScrollToBottomPanel ? ( 287 |
288 | ) : ( 289 | 296 | {commandBarVisible && } 297 | 298 | {({ sticky }) => ( 299 |
300 | {paragraphs.map(paragraph => ( 301 |

302 | {paragraph.startsWith('Button: ') ? ( 303 | 304 | ) : ( 305 | paragraph 306 | )} 307 |

308 | ))} 309 |
310 | )} 311 |
312 | {commandBarVisible && } 313 | {commandBarVisible && } 314 |
315 | )} 316 | 324 |
325 |
326 | {disableScrollToTopPanel ? ( 327 |
328 | ) : ( 329 | 336 | {commandBarVisible && } 337 | 338 | {({ sticky }) => ( 339 |
340 | {[...paragraphs].reverse().map(paragraph => ( 341 |

342 | {paragraph.startsWith('Button: ') ? ( 343 | 344 | ) : ( 345 | paragraph 346 | )} 347 |

348 | ))} 349 |
350 | )} 351 |
352 | {commandBarVisible && } 353 | {commandBarVisible && } 354 |
355 | )} 356 | 360 |
361 |
362 |
363 | react-scroll-to-bottom@{loadedVersion} has loaded. 364 |
365 | {intervalEnabled && } 366 |
367 | ); 368 | }; 369 | 370 | export default App; 371 | -------------------------------------------------------------------------------- /packages/playground/src/CommandBar.js: -------------------------------------------------------------------------------- 1 | /* eslint no-magic-numbers: "off" */ 2 | 3 | import classNames from 'classnames'; 4 | import createEmotion from '@emotion/css/create-instance'; 5 | import React, { useCallback, useMemo, useState } from 'react'; 6 | 7 | import { 8 | useScrollTo, 9 | useScrollToBottom, 10 | useScrollToEnd, 11 | useScrollToStart, 12 | useScrollToTop 13 | } from 'react-scroll-to-bottom'; 14 | 15 | const ROOT_STYLE = { 16 | '&.command-bar': { 17 | backgroundColor: '#FFF', 18 | boxShadow: '0 0 10px rgba(0, 0, 0, .2)', 19 | 20 | '& .command-bar__actions': { 21 | display: 'flex', 22 | listStyleType: 'none', 23 | margin: 0, 24 | padding: 10 25 | }, 26 | 27 | '& .command-bar__action': { 28 | fontSize: 11, 29 | height: 40, 30 | 31 | '&:not(:first-child)': { 32 | marginLeft: 4 33 | } 34 | } 35 | } 36 | }; 37 | 38 | const CommandBar = ({ nonce }) => { 39 | const rootCSS = useMemo(() => createEmotion({ key: 'playground--css-', nonce }).css(ROOT_STYLE), [nonce]); 40 | 41 | const scrollTo = useScrollTo(); 42 | const scrollToBottom = useScrollToBottom(); 43 | const scrollToEnd = useScrollToEnd(); 44 | const scrollToStart = useScrollToStart(); 45 | const scrollToTop = useScrollToTop(); 46 | const [options, setOptions] = useState({ behavior: 'smooth' }); 47 | 48 | const handleScrollTo100pxClick = useCallback(() => scrollTo(100, options), [options, scrollTo]); 49 | const handleScrollToBottomClick = useCallback(() => scrollToBottom(options), [options, scrollToBottom]); 50 | const handleScrollToEndClick = useCallback(() => scrollToEnd(options), [options, scrollToEnd]); 51 | const handleScrollToStartClick = useCallback(() => scrollToStart(options), [options, scrollToStart]); 52 | const handleScrollToTopClick = useCallback(() => scrollToTop(options), [options, scrollToTop]); 53 | const handleSmoothChange = useCallback( 54 | ({ target: { checked } }) => { 55 | setOptions({ behavior: checked ? 'smooth' : 'auto' }); 56 | }, 57 | [setOptions] 58 | ); 59 | 60 | return ( 61 |
62 |
    63 |
  • 64 | 67 |
  • 68 |
  • 69 | 72 |
  • 73 |
  • 74 | 77 |
  • 78 |
  • 79 | 82 |
  • 83 |
  • 84 | 87 |
  • 88 |
  • 89 | 93 |
  • 94 |
95 |
96 | ); 97 | }; 98 | 99 | export default CommandBar; 100 | -------------------------------------------------------------------------------- /packages/playground/src/StatusBar.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import createEmotion from '@emotion/css/create-instance'; 3 | import React, { useMemo, useRef } from 'react'; 4 | 5 | import { 6 | useAnimating, 7 | useAnimatingToEnd, 8 | useAtBottom, 9 | useAtEnd, 10 | useAtStart, 11 | useAtTop, 12 | useMode, 13 | useObserveScrollPosition, 14 | useSticky 15 | } from 'react-scroll-to-bottom'; 16 | 17 | const ROOT_STYLE = { 18 | '&.status-bar': { 19 | backgroundColor: 'rgba(255, 255, 255, .5)', 20 | boxShadow: '0 0 10px rgba(0, 0, 0, .2)', 21 | 22 | '& .status-bar__badges': { 23 | display: 'flex', 24 | listStyleType: 'none', 25 | margin: 0, 26 | padding: 10 27 | }, 28 | 29 | '& .status-bar__badge': { 30 | alignItems: 'center', 31 | backgroundColor: '#DDD', 32 | borderRadius: 5, 33 | display: 'flex', 34 | flex: 1, 35 | fontFamily: 'Arial', 36 | fontSize: '50%', 37 | justifyContent: 'center', 38 | padding: '2px 4px', 39 | textAlign: 'center', 40 | 41 | '&:not(:first-child)': { 42 | marginLeft: 4 43 | }, 44 | 45 | '&.status-bar__badge--lit': { 46 | backgroundColor: 'Red', 47 | color: 'White' 48 | }, 49 | 50 | '&.status-bar__badge--lit-green': { 51 | backgroundColor: 'Green', 52 | color: 'White' 53 | } 54 | } 55 | } 56 | }; 57 | 58 | const StatusBar = ({ className, nonce }) => { 59 | const rootCSS = useMemo(() => createEmotion({ key: 'playground--css-', nonce }).css(ROOT_STYLE), [nonce]); 60 | 61 | const scrollTopRef = useRef(); 62 | const [animating] = useAnimating(); 63 | const [animatingToEnd] = useAnimatingToEnd(); 64 | const [atBottom] = useAtBottom(); 65 | const [atEnd] = useAtEnd(); 66 | const [atStart] = useAtStart(); 67 | const [atTop] = useAtTop(); 68 | const [mode] = useMode(); 69 | const [sticky] = useSticky(); 70 | 71 | useObserveScrollPosition( 72 | ({ scrollTop }) => { 73 | const { current } = scrollTopRef; 74 | 75 | // We are directly writing to "innerText" for performance reason. 76 | if (current) { 77 | current.innerText = scrollTop + 'px'; 78 | } 79 | }, 80 | [scrollTopRef] 81 | ); 82 | 83 | return ( 84 |
85 |
    86 |
  • 87 | STICK TO BOTTOM 88 |
  • 89 |
  • ANIMATING
  • 90 |
  • 91 | ANIMATING TO END 92 |
  • 93 |
  • AT BOTTOM
  • 94 |
  • AT END
  • 95 |
  • AT START
  • 96 |
  • AT TOP
  • 97 |
  • STICKY
  • 98 |
  • 99 |
100 |
101 | ); 102 | }; 103 | 104 | export default StatusBar; 105 | -------------------------------------------------------------------------------- /packages/playground/src/index.js: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import 'react-app-polyfill/stable'; 3 | 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | 7 | import App from './App'; 8 | import reportWebVitals from './reportWebVitals'; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.getElementById('root') 15 | ); 16 | 17 | // If you want to start measuring performance in your app, pass a function 18 | // to log results (for example: reportWebVitals(console.log)) 19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 20 | reportWebVitals(); 21 | -------------------------------------------------------------------------------- /packages/playground/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /packages/playground/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /packages/test-harness/.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /lib 3 | /node_modules 4 | -------------------------------------------------------------------------------- /packages/test-harness/JestEnvironment.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/host/jest/WebDriverEnvironment'); 2 | -------------------------------------------------------------------------------- /packages/test-harness/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false, 7 | "targets": { 8 | "chrome": "80" 9 | } 10 | } 11 | ] 12 | ], 13 | "sourceMaps": "inline" 14 | } 15 | -------------------------------------------------------------------------------- /packages/test-harness/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-harness", 3 | "version": "0.0.0-0", 4 | "description": "", 5 | "author": "William Wong (https://github.com/compulim)", 6 | "license": "MIT", 7 | "main": "lib/index.js", 8 | "private": true, 9 | "bin": { 10 | "browser": "./src/host/dev/index.js" 11 | }, 12 | "browser": { 13 | "selenium-webdriver": false 14 | }, 15 | "engines": { 16 | "node": ">= 14" 17 | }, 18 | "dependencies": { 19 | "abort-controller": "3.0.0", 20 | "core-js": "3.11.0", 21 | "event-target-shim": "6.0.2", 22 | "expect": "25.5.0", 23 | "math-random": "2.0.1" 24 | }, 25 | "devDependencies": { 26 | "@babel/cli": "^7.15.7", 27 | "@babel/core": "^7.15.8", 28 | "@babel/preset-env": "^7.15.8", 29 | "concurrently": "^6.3.0", 30 | "esbuild": "^0.13.6", 31 | "global-agent": "^2.2.0", 32 | "istanbul-lib-coverage": "^3.0.2", 33 | "jest": "^27.2.5", 34 | "jest-environment-node": "^27.2.5", 35 | "jest-image-snapshot": "^4.5.1", 36 | "node-dev": "^6.7.0", 37 | "node-fetch": "^2.6.5", 38 | "nodemon": "^2.0.13", 39 | "p-defer": "^3.0.0", 40 | "selenium-webdriver": "^4.0.0-rc-2", 41 | "serve": "^12.0.1", 42 | "strip-ansi": "^6.0.1" 43 | }, 44 | "scripts": { 45 | "browser": "node ./src/host/dev/index http://localhost:5000/", 46 | "browser:watch": "node-dev --no-notify --respawn ./src/host/dev/index http://localhost:5000/", 47 | "build": "npm run build:babel && npm run build:esbuild", 48 | "build:babel": "babel --config-file ./babel.config.json --out-dir lib src", 49 | "build:esbuild": "esbuild lib/browser/index.js --bundle --define:process.env.CI=undefined --outfile=dist/test-harness.js --sourcemap --target=chrome80", 50 | "prestart": "concurrently \"npm run build:babel:*\" && npm run build:esbuild", 51 | "start": "concurrently --kill-others \"npm run start:*\"", 52 | "start:babel": "npm run build:babel -- --skip-initial-build --watch", 53 | "start:esbuild": "npm run build:esbuild -- --watch", 54 | "test": "jest" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/assertions/became.js: -------------------------------------------------------------------------------- 1 | export default async function became(message, fn, timeout) { 2 | if (typeof timeout !== 'number') { 3 | throw new Error('"timeout" argument must be set.'); 4 | } 5 | 6 | for (const start = Date.now(); Date.now() < start + timeout; ) { 7 | if (await fn()) { 8 | return; 9 | } 10 | 11 | await new Promise(requestAnimationFrame); 12 | } 13 | 14 | throw new Error(`Timed out while waiting for page condition "${message}" after ${timeout / 1000} seconds.`); 15 | } 16 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/assertions/stabilized.js: -------------------------------------------------------------------------------- 1 | import became from './became'; 2 | 3 | export default function stabilized(name, getValue, count, timeout) { 4 | const values = []; 5 | 6 | return became( 7 | `${name} stabilized after ${count} counts`, 8 | async () => { 9 | const value = getValue(); 10 | 11 | // Push the current value into the bucket. 12 | values.push(value); 13 | 14 | // We only need the last X values. 15 | while (values.length > count) { 16 | values.shift(); 17 | } 18 | 19 | // Check if we already got X number of values, and all of them are the same value. 20 | if (values.length === count && values.every(value => Object.is(value, values[0]))) { 21 | return true; 22 | } 23 | 24 | // If not, sleep for a frame and check again. 25 | await new Promise(requestAnimationFrame); 26 | 27 | return false; 28 | }, 29 | timeout 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/globals/became.js: -------------------------------------------------------------------------------- 1 | import became from '../assertions/became'; 2 | 3 | export default function () { 4 | return window.became || (window.became = became); 5 | } 6 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/globals/expect.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | 3 | export default function () { 4 | return window.expect || (window.expect = expect); 5 | }; 6 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/globals/host.js: -------------------------------------------------------------------------------- 1 | import host from '../proxies/host'; 2 | import rpc from '../../common/rpc'; 3 | import webDriverPort from './webDriverPort'; 4 | 5 | /** Assigns remote `host` object from Jest to global. */ 6 | export default function () { 7 | return window.host || (window.host = rpc('host', host(), [window, webDriverPort()])); 8 | } 9 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/globals/run.js: -------------------------------------------------------------------------------- 1 | import getHost from './host'; 2 | 3 | export default function () { 4 | return ( 5 | window.run || 6 | (window.run = (fn, doneOptions) => { 7 | const host = getHost(); 8 | 9 | window.addEventListener('error', event => host.error(event.error)); 10 | 11 | // Run the test, signal start by host.ready(). 12 | // On success or failure, call host.done() or host.error() correspondingly. 13 | return Promise.resolve() 14 | .then(host.ready) 15 | .then(fn) 16 | .then(() => host.done(doneOptions)) 17 | .catch(host.error); 18 | }) 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/globals/sleep.js: -------------------------------------------------------------------------------- 1 | import sleep from '../../common/utils/sleep'; 2 | 3 | export default function () { 4 | return window.sleep || (window.sleep = sleep); 5 | } 6 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/globals/stabilized.js: -------------------------------------------------------------------------------- 1 | import stabilized from '../assertions/stabilized'; 2 | 3 | export default function () { 4 | return window.stabilized || (window.stabilized = stabilized); 5 | } 6 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/globals/webDriver.js: -------------------------------------------------------------------------------- 1 | import rpc from '../../common/rpc'; 2 | import webDriverPort from './webDriverPort'; 3 | 4 | const CHAINABLES = [ 5 | 'click', 6 | 'contextClick', 7 | 'doubleClick', 8 | 'dragAndDrop', 9 | 'keyDown', 10 | 'keyUp', 11 | 'move', 12 | 'pause', 13 | 'press', 14 | 'release', 15 | 'sendKeys', 16 | 'synchronize' 17 | ]; 18 | 19 | function createActions(proxy) { 20 | return function actions() { 21 | const chain = []; 22 | const target = {}; 23 | 24 | CHAINABLES.forEach(name => { 25 | target[name] = (...args) => { 26 | chain.push([name, ...args]); 27 | 28 | return target; 29 | }; 30 | }); 31 | 32 | target.perform = () => proxy.performActions(chain); 33 | 34 | return target; 35 | }; 36 | } 37 | 38 | export default function () { 39 | const proxy = rpc( 40 | 'webDriver', 41 | { 42 | click: () => {}, 43 | performActions: () => {}, 44 | sendDevToolsCommand: () => {}, 45 | takeScreenshot: () => {}, 46 | windowSize: () => {} 47 | }, 48 | [window, webDriverPort()] 49 | ); 50 | 51 | return ( 52 | window.webDriver || 53 | (window.webDriver = { 54 | ...proxy, 55 | actions: createActions(proxy), 56 | performActions: undefined 57 | }) 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/globals/webDriverPort.js: -------------------------------------------------------------------------------- 1 | export default function webDriverPort() { 2 | return ( 3 | window.webDriverPort || 4 | (window.webDriverPort = { 5 | __queue: [], 6 | postMessage: data => window.webDriverPort.__queue.push({ data, origin: location.href }) 7 | }) 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/index.js: -------------------------------------------------------------------------------- 1 | import became from './globals/became'; 2 | import expect from './globals/expect'; 3 | import host from './globals/host'; 4 | import run from './globals/run'; 5 | import sleep from './globals/sleep'; 6 | import stabilized from './globals/stabilized'; 7 | import webDriver from './globals/webDriver'; 8 | import webDriverPort from './globals/webDriverPort'; 9 | 10 | became(); 11 | expect(); 12 | host(); 13 | run(); 14 | sleep(); 15 | stabilized(); 16 | webDriver(); 17 | webDriverPort(); 18 | -------------------------------------------------------------------------------- /packages/test-harness/src/browser/proxies/host.js: -------------------------------------------------------------------------------- 1 | /* eslint no-empty-function: "off" */ 2 | 3 | /** RPC object on the browser side. */ 4 | export default function createHost() { 5 | // Modifying this map will also requires modifying the corresponding RPC dummy at /src/host/common/host/index.js. 6 | // Since Jest do not need to call the browser, it can use executeScript() instead, all implementations here are dummy. 7 | return { 8 | done: () => {}, 9 | error: () => {}, 10 | getLogs: () => {}, 11 | ready: () => {}, 12 | snapshot: () => {} 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /packages/test-harness/src/common/marshal.js: -------------------------------------------------------------------------------- 1 | const SeleniumWebDriver = require('selenium-webdriver'); 2 | 3 | // "selenium-webdriver" is undefined if running under browser. 4 | const { WebElement } = SeleniumWebDriver || {}; 5 | 6 | // Format a JavaScript object to another format that is okay to send over the Web Driver protocol. 7 | module.exports = function marshal(value) { 8 | if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') { 9 | return value; 10 | } else if (typeof value === 'undefined') { 11 | return { __type: 'undefined' }; 12 | } else if (!value) { 13 | return value; 14 | } else if (Array.isArray(value)) { 15 | return value.map(value => marshal(value)); 16 | } else if ([].toString.call(value) === '[object Object]') { 17 | return Object.fromEntries( 18 | Object.entries(value).map(([name, value]) => 19 | name !== '__proto__' && name !== 'constructor' && name !== 'prototype' ? [name, marshal(value)] : [name] 20 | ) 21 | ); 22 | } else if (typeof window !== 'undefined' && value instanceof window.HTMLElement) { 23 | return value; 24 | } else if (typeof WebElement !== 'undefined' && value instanceof WebElement) { 25 | return value; 26 | } else if (value instanceof Error) { 27 | return { 28 | __type: 'error', 29 | message: value.message, 30 | stack: value.stack 31 | }; 32 | } 33 | 34 | console.error('Cannot marshal object.', value); 35 | 36 | throw new Error('Cannot marshal object.'); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/test-harness/src/common/rpc.js: -------------------------------------------------------------------------------- 1 | // "selenium-webdriver" is undefined if running under browser. 2 | const random = require('math-random'); 3 | 4 | const marshal = require('./marshal'); 5 | const unmarshal = require('./unmarshal'); 6 | 7 | /** 8 | * Enables remoting to an object over receive/send ports using a RPC mechanism. 9 | * 10 | * This implementation only support arguments of primitive types. Look at `marshal.js` and `unmarshal.js` for supported types. 11 | * It does not support arguments of functions, such as callback functions. 12 | */ 13 | module.exports = function rpc(rpcName, fns, [receivePort, sendPort]) { 14 | const invocations = {}; 15 | 16 | receivePort.addEventListener('message', async ({ data }) => { 17 | const { type } = data || {}; 18 | 19 | if (!/^rpc:/u.test(type) || data.rpcName !== rpcName) { 20 | return; 21 | } 22 | 23 | data = unmarshal(data); 24 | 25 | /* eslint-disable-next-line default-case */ 26 | switch (type) { 27 | case 'rpc:call': 28 | if (data.fn === '__proto__' || data.fn === 'constructor' || data.fn === 'prototype') { 29 | return; 30 | } 31 | 32 | try { 33 | const returnValue = await fns[data.fn](...data.args); 34 | 35 | sendPort.postMessage({ 36 | invocationID: data.invocationID, 37 | returnValue, 38 | rpcName, 39 | type: 'rpc:return' 40 | }); 41 | } catch ({ message, stack }) { 42 | sendPort.postMessage({ 43 | error: { message, stack }, 44 | invocationID: data.invocationID, 45 | rpcName, 46 | type: 'rpc:error' 47 | }); 48 | } 49 | 50 | break; 51 | 52 | case 'rpc:return': 53 | { 54 | const { [data.invocationID]: { resolve } = {} } = invocations; 55 | 56 | resolve && resolve(data.returnValue); 57 | } 58 | 59 | break; 60 | 61 | case 'rpc:error': 62 | { 63 | const { [data.invocationID]: { reject } = {} } = invocations; 64 | const error = new Error(data.error.message); 65 | 66 | error.stack = data.error.stack; 67 | 68 | reject && reject(error); 69 | } 70 | 71 | break; 72 | } 73 | }); 74 | 75 | return Object.fromEntries( 76 | Object.entries(fns).map(([fn, value]) => [ 77 | fn, 78 | typeof value === 'function' 79 | ? (...args) => 80 | new Promise((resolve, reject) => { 81 | // eslint-disable-next-line no-magic-numbers 82 | const invocationID = random().toString(36).substr(2, 5); 83 | 84 | invocations[invocationID] = { reject, resolve }; 85 | 86 | sendPort.postMessage(marshal({ args, fn, invocationID, rpcName, type: 'rpc:call' })); 87 | }) 88 | : value 89 | ]) 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /packages/test-harness/src/common/unmarshal.js: -------------------------------------------------------------------------------- 1 | const SeleniumWebDriver = require('selenium-webdriver'); 2 | 3 | // "selenium-webdriver" is undefined if running under browser. 4 | const { WebElement } = SeleniumWebDriver || {}; 5 | 6 | // Unformat a JavaScript object from another format received over the Web Driver protocol. 7 | module.exports = function unmarshal(value) { 8 | if (!value) { 9 | return value; 10 | } else if (typeof window !== 'undefined' && value instanceof window.HTMLElement) { 11 | return value; 12 | } else if (typeof WebElement !== 'undefined' && value instanceof WebElement) { 13 | return value; 14 | } else if (Array.isArray(value)) { 15 | return value.map(value => unmarshal(value)); 16 | } else if ([].toString.call(value) === '[object Object]') { 17 | if (value.__type === 'error') { 18 | const error = new Error(value.message); 19 | 20 | error.stack = value.stack; 21 | 22 | return error; 23 | } else if (value.__type === 'undefined') { 24 | return; 25 | } 26 | 27 | return Object.fromEntries( 28 | Object.entries(value).map(([name, value]) => 29 | name !== '__proto__' && name !== 'constructor' && name !== 'prototype' ? [name, unmarshal(value)] : [name] 30 | ) 31 | ); 32 | } 33 | 34 | return value; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/test-harness/src/common/utils/signalToReject.js: -------------------------------------------------------------------------------- 1 | module.exports = function signalToReject(signal) { 2 | return new Promise( 3 | (_, reject) => signal && signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) 4 | ); 5 | }; 6 | -------------------------------------------------------------------------------- /packages/test-harness/src/common/utils/sleep.js: -------------------------------------------------------------------------------- 1 | const signalToReject = require('./signalToReject'); 2 | 3 | module.exports = function sleep(duration = 1000, signal) { 4 | return Promise.race([new Promise(resolve => setTimeout(resolve, duration)), signalToReject(signal)]); 5 | }; 6 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/createHostBridge.js: -------------------------------------------------------------------------------- 1 | const { EventTarget, Event } = require('event-target-shim'); 2 | const AbortController = require('abort-controller'); 3 | 4 | const sleep = require('../../common/utils/sleep'); 5 | 6 | class HostBridgePort { 7 | constructor(driver, signal) { 8 | this.driver = driver; 9 | this.signal = signal; 10 | } 11 | 12 | postMessage(data) { 13 | if (this.signal.aborted) { 14 | return; 15 | } 16 | 17 | /* istanbul ignore next */ 18 | this.driver.executeScript(data => { 19 | const event = new Event('message'); 20 | 21 | event.data = data; 22 | event.origin = 'wd://'; 23 | 24 | window.dispatchEvent(event); 25 | }, data); 26 | } 27 | } 28 | 29 | /** 30 | * This is a bridge to talk to the JavaScript VM in the browser, via Web Driver executeScript(). 31 | * The object pattern is based on W3C MessageChannel and MessagePort standard. 32 | */ 33 | class HostBridge extends EventTarget { 34 | constructor(driver) { 35 | super(); 36 | 37 | this.abortController = new AbortController(); 38 | this.browser = new HostBridgePort(driver, this.abortController.signal); 39 | this.start(driver); 40 | } 41 | 42 | close() { 43 | this.abortController.abort(); 44 | } 45 | 46 | async start(driver) { 47 | try { 48 | for (; !this.abortController.signal.aborted; ) { 49 | /* istanbul ignore next */ 50 | const result = await driver.executeScript(() => window.webDriverPort && window.webDriverPort.__queue.shift()); 51 | 52 | if (this.abortController.signal.aborted) { 53 | break; 54 | } 55 | 56 | if (!result) { 57 | await sleep(100); 58 | } else { 59 | const event = new Event('message'); 60 | 61 | event.data = result.data; 62 | event.origin = result.origin; 63 | 64 | this.dispatchEvent(event); 65 | } 66 | } 67 | } catch (err) { 68 | if (err.name !== 'NoSuchSessionError' && err.name !== 'NoSuchWindowError' && err.name !== 'WebDriverError') { 69 | throw err; 70 | } 71 | } 72 | } 73 | } 74 | 75 | module.exports = function createHostBridge(driver) { 76 | return new HostBridge(driver); 77 | }; 78 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/createProxies.js: -------------------------------------------------------------------------------- 1 | const createHost = require('./host/index'); 2 | const createWebDriver = require('./webDriver/index'); 3 | 4 | module.exports = function createProxies(driver) { 5 | return { 6 | host: createHost(driver), 7 | webDriver: createWebDriver(driver) 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/dumpLogs.js: -------------------------------------------------------------------------------- 1 | const getBrowserLogs = require('./getBrowserLogs'); 2 | 3 | function formatLogEntries(entries) { 4 | return entries 5 | .map(({ level: { name }, message }) => { 6 | let text = message.split(' ').slice(2).join(' '); 7 | 8 | if (text.length > 1000) { 9 | text = text.slice(0, 1000) + '…'; 10 | } 11 | 12 | return `📃 [${name}] ${text}`; 13 | }) 14 | .join('\n'); 15 | } 16 | 17 | module.exports = async function dumpLogs(webDriver, { clear } = {}) { 18 | let logs; 19 | 20 | try { 21 | logs = await getBrowserLogs(webDriver, { clear }); 22 | } catch (err) { 23 | logs = []; 24 | } 25 | 26 | logs.length && console.log(formatLogEntries(logs)); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/getBrowserLogs.js: -------------------------------------------------------------------------------- 1 | const { logging } = require('selenium-webdriver'); 2 | 3 | const IGNORE_CONSOLE_MESSAGE_FRAGMENTS = [ 4 | '[TESTHARNESS]', 5 | 'favicon.ico', 6 | 'in-browser Babel transformer', 7 | 'react-devtools' 8 | ]; 9 | 10 | module.exports = async function getBrowserLogs(webDriver, { clear = false } = {}) { 11 | // Every calls to webDriver.manage().logs().get() will clean up the log. 12 | // This function will persist the logs across function calls, until `clear` is set to `true`. 13 | const newLogs = (await webDriver.manage().logs().get(logging.Type.BROWSER)).filter( 14 | // Ignore console entries that contains specified fragments. 15 | ({ message }) => 16 | !IGNORE_CONSOLE_MESSAGE_FRAGMENTS.some(ignoreFragment => 17 | ignoreFragment instanceof RegExp ? ignoreFragment.test(message) : ~message.indexOf(ignoreFragment) 18 | ) 19 | ); 20 | 21 | const logs = (global.__logs = [...(global.__logs || []), ...newLogs]); 22 | 23 | if (clear) { 24 | delete global.__logs; 25 | } 26 | 27 | return logs; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/host/done.js: -------------------------------------------------------------------------------- 1 | const { logging } = require('selenium-webdriver'); 2 | const getBrowserLogs = require('../getBrowserLogs'); 3 | 4 | function isDeprecation(message) { 5 | return message.includes('deprecate'); 6 | } 7 | 8 | module.exports = (webDriver, resolve) => { 9 | return async function done({ expectDeprecations = false, ignoreErrors = false } = {}) { 10 | const entries = await getBrowserLogs(webDriver); 11 | 12 | if (expectDeprecations) { 13 | expect(entries.some(({ message }) => isDeprecation(message))).toBeTruthy(); 14 | } 15 | 16 | // Check if there are any console.error. 17 | if (!ignoreErrors && logging) { 18 | expect(entries.filter(({ level }) => level === logging.Level.SEVERE)).toHaveLength(0); 19 | } 20 | 21 | resolve(); 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/host/error.js: -------------------------------------------------------------------------------- 1 | module.exports = reject => { 2 | return function error(error) { 3 | reject(error); 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/host/getLogs.js: -------------------------------------------------------------------------------- 1 | const getBrowserLogs = require('../getBrowserLogs'); 2 | 3 | module.exports = webDriver => { 4 | return function getLogs() { 5 | return getBrowserLogs(webDriver); 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/host/index.js: -------------------------------------------------------------------------------- 1 | const createDeferred = require('p-defer'); 2 | 3 | const done = require('./done'); 4 | const error = require('./error'); 5 | const getLogs = require('./getLogs'); 6 | const ready = require('./ready'); 7 | const snapshot = require('./snapshot'); 8 | 9 | /** RPC object on the Jest side. */ 10 | module.exports = function createHost(webDriver) { 11 | const doneDeferred = createDeferred(); 12 | const readyDeferred = createDeferred(); 13 | 14 | // Modifying this map will also requires modifying the corresponding RPC dummy at /src/browser/proxies/host.js 15 | return { 16 | done: done(webDriver, doneDeferred.resolve), 17 | donePromise: doneDeferred.promise, 18 | error: error(doneDeferred.reject), 19 | getLogs: getLogs(webDriver), 20 | ready: ready(readyDeferred.resolve), 21 | readyPromise: readyDeferred.promise, 22 | snapshot: snapshot(webDriver), 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/host/ready.js: -------------------------------------------------------------------------------- 1 | module.exports = resolve => 2 | function ready() { 3 | resolve(); 4 | }; 5 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/host/snapshot.js: -------------------------------------------------------------------------------- 1 | const sleep = require('../../../common/utils/sleep'); 2 | 3 | const TIME_FOR_IMAGE_COMPLETE = 5000; 4 | 5 | module.exports = webDriver => 6 | async function snapshot(options) { 7 | // Wait until all images are loaded/errored. 8 | for (const start = Date.now(); Date.now() - start < TIME_FOR_IMAGE_COMPLETE; ) { 9 | if ( 10 | await webDriver.executeScript( 11 | /* istanbul ignore next */ 12 | () => [].every.call(document.getElementsByTagName('img'), ({ complete }) => complete) 13 | ) 14 | ) { 15 | break; 16 | } 17 | 18 | sleep(100); 19 | } 20 | 21 | // TODO: Should we take multiple screenshot and wait until it stabilized before matching image snapshots? 22 | await expect(webDriver.takeScreenshot()).resolves.toMatchImageSnapshot(options); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/registerProxies.js: -------------------------------------------------------------------------------- 1 | const createHostBridge = require('./createHostBridge'); 2 | const rpc = require('../../common/rpc'); 3 | 4 | /** Registers a map of proxy, which the HTML test can send RPC calls. */ 5 | module.exports = function registerProxies(webDriver, proxies) { 6 | const bridge = createHostBridge(webDriver); 7 | 8 | Object.entries(proxies).forEach(([name, proxy]) => rpc(name, proxy, [bridge, bridge.browser])); 9 | 10 | return bridge; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/webDriver/click.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | return function click(element) { 3 | return element.click(); 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/webDriver/index.js: -------------------------------------------------------------------------------- 1 | const click = require('./click'); 2 | const performActions = require('./performActions'); 3 | const sendDevToolsCommand = require('./sendDevToolsCommand'); 4 | const takeScreenshot = require('./takeScreenshot'); 5 | const windowSize = require('./windowSize'); 6 | 7 | /** RPC object on the Jest side. */ 8 | module.exports = function createWebDriver(webDriver) { 9 | // Modifying this map will also requires modifying the corresponding RPC dummy at /src/browser/proxies/webDriver.js 10 | return { 11 | click: click(webDriver), 12 | performActions: performActions(webDriver), 13 | sendDevToolsCommand: sendDevToolsCommand(webDriver), 14 | takeScreenshot: takeScreenshot(webDriver), 15 | windowSize: windowSize(webDriver) 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/webDriver/performActions.js: -------------------------------------------------------------------------------- 1 | module.exports = function createPerformActions(webDriver) { 2 | return async function performActions(chain) { 3 | let actions = webDriver.actions(); 4 | 5 | chain.forEach(([name, ...args]) => { 6 | if (name !== '__proto__' && name !== 'constructor' && name !== 'prototype') { 7 | actions = actions[name](...args); 8 | } 9 | }); 10 | 11 | return await actions.perform(); 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/webDriver/sendDevToolsCommand.js: -------------------------------------------------------------------------------- 1 | module.exports = function sendDevToolsCommand(webDriver) { 2 | return (command, parameters) => webDriver.sendDevToolsCommand(command, parameters); 3 | }; 4 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/webDriver/takeScreenshot.js: -------------------------------------------------------------------------------- 1 | module.exports = function createTakeScreenshot(webDriver) { 2 | return () => webDriver.takeScreenshot(); 3 | }; 4 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/common/webDriver/windowSize.js: -------------------------------------------------------------------------------- 1 | module.exports = function createWindowSize(webDriver) { 2 | return async function windowSize(nextWidth, nextHeight) { 3 | const window = webDriver.manage().window(); 4 | const { height, width } = await window.getRect(); 5 | 6 | await window.setRect({ height: nextHeight || height, width: nextWidth || width }); 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/dev/createDevProxies.js: -------------------------------------------------------------------------------- 1 | const createProxies = require('../common/createProxies'); 2 | const done = require('./hostOverrides/done'); 3 | const error = require('./hostOverrides/error'); 4 | const snapshot = require('./hostOverrides/snapshot'); 5 | const windowSize = require('./webDriverOverrides/windowSize'); 6 | 7 | module.exports = function createDevProxies(driver) { 8 | const { host, webDriver, ...proxies } = createProxies(driver); 9 | 10 | return { 11 | host: { 12 | ...host, 13 | done: done(driver, host.done), 14 | error: error(driver, host.error), 15 | snapshot: snapshot(driver, host.snapshot) 16 | }, 17 | webDriver: { 18 | ...webDriver, 19 | windowSize: windowSize(driver, host.windowSize) 20 | }, 21 | ...proxies 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/dev/hostOverrides/done.js: -------------------------------------------------------------------------------- 1 | // In dev mode, draw a green tick when test succeeded. 2 | 3 | const dumpLogs = require('../../common/dumpLogs'); 4 | const override = require('../utils/override'); 5 | 6 | // Send the completion back to the browser console. 7 | module.exports = (webDriver, done) => 8 | override(done, undefined, async function done() { 9 | /* istanbul ignore next */ 10 | await webDriver.executeScript(() => { 11 | console.log( 12 | '%c✔️ DONE%c', 13 | 'background-color: green; border-radius: 4px; color: white; font-size: 200%; padding: 2px 4px;', 14 | '' 15 | ); 16 | 17 | const div = document.createElement('div'); 18 | 19 | div.setAttribute( 20 | 'style', 21 | 'align-items: center; background-color: green; border: solid 4px black; border-radius: 10px; bottom: 10px; display: flex; font-size: 60px; height: 100px; justify-content: center; position: fixed; right: 10px; width: 100px;' 22 | ); 23 | 24 | div.textContent = '✔️'; 25 | 26 | document.body.appendChild(div); 27 | }); 28 | 29 | await dumpLogs(webDriver, { clear: true }); 30 | 31 | global.__logs = []; 32 | }); 33 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/dev/hostOverrides/error.js: -------------------------------------------------------------------------------- 1 | // In dev mode, draw a red cross when test failed. 2 | 3 | const dumpLogs = require('../../common/dumpLogs'); 4 | const override = require('../utils/override'); 5 | const stripANSI = require('strip-ansi'); 6 | 7 | // Send the error back to the browser console. 8 | module.exports = (webDriver, error) => 9 | override(error, async function error(error) { 10 | /* istanbul ignore next */ 11 | await webDriver.executeScript( 12 | (message, stack) => { 13 | const error = new Error(message); 14 | 15 | error.stack = stack; 16 | 17 | console.error(error); 18 | 19 | console.log( 20 | '%c❌ FAILED%c', 21 | 'background-color: red; border-radius: 4px; color: white; font-size: 200%; padding: 2px 4px;', 22 | '' 23 | ); 24 | 25 | const div = document.createElement('div'); 26 | 27 | div.setAttribute( 28 | 'style', 29 | 'align-items: center; background-color: red; border: solid 4px black; border-radius: 10px; bottom: 10px; display: flex; font-size: 60px; height: 100px; justify-content: center; position: fixed; right: 10px; width: 100px;' 30 | ); 31 | 32 | div.textContent = '❌'; 33 | 34 | document.body.appendChild(div); 35 | }, 36 | stripANSI(error.message), 37 | stripANSI(error.stack) 38 | ); 39 | 40 | await dumpLogs(webDriver, { clear: true }); 41 | 42 | global.__logs = []; 43 | }); 44 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/dev/hostOverrides/snapshot.js: -------------------------------------------------------------------------------- 1 | // In dev mode, we output the screenshot in console, instead of checking against a PNG file. 2 | 3 | module.exports = webDriver => 4 | async function snapshot() { 5 | const base64 = await webDriver.takeScreenshot(); 6 | 7 | await webDriver.executeScript( 8 | /* istanbul ignore next */ 9 | (message, url) => { 10 | console.groupCollapsed(message); 11 | console.log(url); 12 | console.groupEnd(); 13 | }, 14 | '[TESTHARNESS] Snapshot taken.', 15 | `data:image/png;base64,${base64}` 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/dev/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // This will run an instance of Chrome via Web Driver locally. 4 | // It will be headed (vs. headless) and is used for development purpose only. 5 | 6 | const { Builder, logging } = require('selenium-webdriver'); 7 | const { Options: ChromeOptions, ServiceBuilder: ChromeServiceBuilder } = require('selenium-webdriver/chrome'); 8 | const AbortController = require('abort-controller'); 9 | const expect = require('expect'); 10 | const fetch = require('node-fetch'); 11 | 12 | const createDevProxies = require('./createDevProxies'); 13 | const findHostIP = require('./utils/findHostIP'); 14 | const findLocalIP = require('./utils/findLocalIP'); 15 | const registerProxies = require('../common/registerProxies'); 16 | const setAsyncInterval = require('./utils/setAsyncInterval'); 17 | const sleep = require('../../common/utils/sleep'); 18 | 19 | const ONE_DAY = 86400000; 20 | 21 | global.expect = expect; 22 | 23 | async function main() { 24 | const abortController = new AbortController(); 25 | const hostIP = await findHostIP(); 26 | const localIP = await findLocalIP(); 27 | 28 | const service = await new ChromeServiceBuilder('./chromedriver.exe') 29 | .addArguments('--allowed-ips', localIP) 30 | .setHostname(hostIP) 31 | .setStdio(['ignore', 'ignore', 'ignore']) 32 | .build(); 33 | 34 | const webDriverURL = await service.start(10000); 35 | 36 | try { 37 | const preferences = new logging.Preferences(); 38 | 39 | preferences.setLevel(logging.Type.BROWSER, logging.Level.ALL); 40 | 41 | const webDriver = await new Builder() 42 | .forBrowser('chrome') 43 | .setChromeOptions(new ChromeOptions().setLoggingPrefs(preferences)) 44 | .usingServer(webDriverURL) 45 | .build(); 46 | 47 | const sessionId = (await webDriver.getSession()).getId(); 48 | 49 | const terminate = async () => { 50 | abortController.abort(); 51 | // WebDriver.quit() will kill all async function for executeScript(). 52 | // HTTP DELETE will kill the session. 53 | // Combining two will forcifully killed the Web Driver session immediately. 54 | 55 | try { 56 | webDriver.quit(); // Don't await or Promise.all on quit(). 57 | 58 | await fetch(new URL(sessionId, webDriverURL), { method: 'DELETE', timeout: 2000 }); 59 | 60 | // eslint-disable-next-line no-empty 61 | } catch (err) {} 62 | }; 63 | 64 | process.once('SIGINT', terminate); 65 | process.once('SIGTERM', terminate); 66 | 67 | try { 68 | await webDriver.get(process.argv[2] || 'http://localhost:5000/'); 69 | 70 | registerProxies(webDriver, createDevProxies(webDriver)); 71 | 72 | setAsyncInterval( 73 | async () => { 74 | try { 75 | await webDriver.getWindowHandle(); 76 | } catch (err) { 77 | abortController.abort(); 78 | } 79 | }, 80 | 2000, 81 | abortController.signal 82 | ); 83 | 84 | await sleep(ONE_DAY, abortController.signal); 85 | } finally { 86 | await terminate(); 87 | } 88 | } finally { 89 | await service.kill(); 90 | } 91 | } 92 | 93 | main().catch(err => { 94 | err.message === 'aborted' || console.error(err); 95 | 96 | process.exit(); 97 | }); 98 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/dev/utils/findHostIP.js: -------------------------------------------------------------------------------- 1 | const { readFile } = require('fs').promises; 2 | const isWSL2 = require('./isWSL2'); 3 | 4 | /** Finds the Windows (host) IP address when running under WSL2. */ 5 | module.exports = async function findHostIP() { 6 | if (await isWSL2()) { 7 | try { 8 | const content = await readFile('/etc/resolv.conf'); 9 | 10 | return /^nameserver\s(.*)/mu.exec(content)[1]; 11 | 12 | // eslint-disable-next-line no-empty 13 | } catch (err) {} 14 | } 15 | 16 | return 'localhost'; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/dev/utils/findLocalIP.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require('child_process'); 2 | const isWSL2 = require('./isWSL2'); 3 | 4 | /** Finds the Linux IP address when running under WSL2. */ 5 | module.exports = async function findLocalIP() { 6 | if (await isWSL2()) { 7 | try { 8 | const childProcess = spawn('hostname', ['-I']); 9 | 10 | return new Promise(resolve => { 11 | const chunks = []; 12 | 13 | childProcess.stdout.on('data', chunk => chunks.push(chunk)); 14 | childProcess.on('close', () => resolve(Buffer.concat(chunks).toString())); 15 | }); 16 | 17 | // eslint-disable-next-line no-empty 18 | } catch (err) {} 19 | } 20 | 21 | return 'localhost'; 22 | }; 23 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/dev/utils/isWSL2.js: -------------------------------------------------------------------------------- 1 | const { readFile } = require('fs').promises; 2 | 3 | /** Returns `true` if running under WSL2, otherwise, `false`. */ 4 | module.exports = async function isWSL2() { 5 | try { 6 | // https://docs.microsoft.com/en-us/windows/wsl/compare-versions#accessing-windows-networking-apps-from-linux-host-ip 7 | const procVersion = await readFile('/proc/version'); 8 | 9 | return /wsl2/iu.test(procVersion); 10 | } catch (err) { 11 | return false; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/dev/utils/override.js: -------------------------------------------------------------------------------- 1 | // Override a function by pre/post functions, and optionally async function. 2 | 3 | // Assume the original function signature is (x: number, y: number) => number. 4 | // - "pre" will be (x: number, y: number) => [number, number], which intercept and modify the passing arguments; 5 | // - "post" will be (number) => number, which intercept and modify the returning value. 6 | 7 | module.exports = function override(fn, pre = (...args) => args, post = result => result) { 8 | return async (...args) => post(await fn(...((await pre(...args)) || []))); 9 | }; 10 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/dev/utils/setAsyncInterval.js: -------------------------------------------------------------------------------- 1 | // This is similar to setInterval: 2 | // - Instead of using a strict and sync interval, it will wait for X milliseconds AFTER the last async resolution; 3 | // - Instead of clearAsyncInterval, we will use AbortController instead. 4 | 5 | module.exports = function setAsyncInterval(fn, interval, signal) { 6 | const once = async () => { 7 | if (signal.aborted) { 8 | return; 9 | } 10 | 11 | await fn(); 12 | 13 | schedule(); 14 | }; 15 | 16 | const schedule = () => { 17 | if (!signal.aborted) { 18 | setTimeout(once, interval); 19 | } 20 | }; 21 | 22 | schedule(); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/dev/webDriverOverrides/windowSize.js: -------------------------------------------------------------------------------- 1 | // In dev mode, we don't resize the window. 2 | 3 | module.exports = webDriver => 4 | async function windowSize(width, height, element) { 5 | /* istanbul ignore next */ 6 | element && 7 | (await webDriver.executeScript( 8 | (element, width, height) => { 9 | if (width) { 10 | element.style.width = width + 'px'; 11 | } 12 | 13 | if (height) { 14 | element.style.height = height + 'px'; 15 | } 16 | }, 17 | element, 18 | width, 19 | height 20 | )); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/jest/WebDriverEnvironment.js: -------------------------------------------------------------------------------- 1 | require('global-agent/bootstrap'); 2 | 3 | const { join } = require('path'); 4 | const NodeEnvironment = require('jest-environment-node'); 5 | 6 | class WebDriverEnvironment extends NodeEnvironment { 7 | constructor(config, context) { 8 | super(config, context); 9 | 10 | // Copying normalized options to global, so it can read by `runHTML`. 11 | this.global.__webDriverEnvironmentOptions__ = { 12 | testURL: 'http://localhost:5000/', 13 | webDriverURL: 'http://localhost:4444/wd/hub/', 14 | ...config.testEnvironmentOptions 15 | }; 16 | 17 | config.setupFilesAfterEnv.push(join(__dirname, 'runHTML.js'), join(__dirname, 'setupToMatchImageSnapshot.js')); 18 | } 19 | } 20 | 21 | module.exports = WebDriverEnvironment; 22 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/jest/allocateWebDriver.js: -------------------------------------------------------------------------------- 1 | const { Builder, logging } = require('selenium-webdriver'); 2 | const { Options: ChromeOptions } = require('selenium-webdriver/chrome'); 3 | 4 | module.exports = async function allocateWebDriver({ webDriverURL }) { 5 | global.__operation__ = 'allocating Web Driver session'; 6 | 7 | const preferences = new logging.Preferences(); 8 | 9 | preferences.setLevel(logging.Type.BROWSER, logging.Level.ALL); 10 | 11 | const builder = new Builder() 12 | .forBrowser('chrome') 13 | .setChromeOptions(new ChromeOptions().addArguments('--single-process').headless().setLoggingPrefs(preferences)); 14 | 15 | const webDriver = (global.webDriver = await builder.usingServer(webDriverURL).build()); 16 | 17 | return webDriver; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/jest/mergeCoverageMap.js: -------------------------------------------------------------------------------- 1 | const { createCoverageMap } = require('istanbul-lib-coverage'); 2 | 3 | module.exports = function mergeCoverageMap(...coverageMaps) { 4 | const map = createCoverageMap(); 5 | 6 | const addFileCoverage = map.addFileCoverage.bind(map); 7 | 8 | coverageMaps.forEach(coverageMap => Object.values(coverageMap || {}).forEach(addFileCoverage)); 9 | 10 | // map.toJSON() does not return a plain object but a serializable object. 11 | // Jest expects a plain object, thus, we need to stringify/parse to make it a pure JSON. 12 | // Otherwise, Jest will throw "Invalid file coverage object, missing keys, found:data". 13 | return JSON.parse(JSON.stringify(map.toJSON())); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/jest/runHTML.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | const { basename, join } = require('path'); 3 | const { tmpdir } = require('os'); 4 | const { writeFile } = require('fs').promises; 5 | const allocateWebDriver = require('./allocateWebDriver'); 6 | const createProxies = require('../common/createProxies'); 7 | const dumpLogs = require('../common/dumpLogs'); 8 | const mergeCoverageMap = require('./mergeCoverageMap'); 9 | const registerProxies = require('../common/registerProxies'); 10 | const sleep = require('../../common/utils/sleep'); 11 | 12 | afterEach(async () => { 13 | try { 14 | // We must stop the bridge too, otherwise, it will cause timeout. 15 | global.webDriverBridge && global.webDriverBridge.close(); 16 | 17 | // eslint-disable-next-line no-empty 18 | } catch (err) {} 19 | 20 | global.__operation__ && console.log(`Last operation was ${global.__operation__}`); 21 | 22 | const { webDriver } = global; 23 | 24 | if (webDriver) { 25 | try { 26 | await dumpLogs(webDriver); 27 | 28 | // eslint-disable-next-line no-empty 29 | } catch (err) {} 30 | 31 | try { 32 | // Exceptions thrown in setup() will still trigger afterEach(), such as timeout. 33 | await webDriver.quit(); 34 | 35 | // eslint-disable-next-line no-empty 36 | } catch (err) {} 37 | } 38 | }); 39 | 40 | global.runHTML = async function runHTML(url, options) { 41 | options = { ...global.__webDriverEnvironmentOptions__, ...options }; 42 | 43 | // We are assigning it to "global.webDriver" to allow Environment.teardown to terminate it if needed. 44 | const webDriver = (global.webDriver = await allocateWebDriver(options)); 45 | 46 | try { 47 | const absoluteURL = new URL(url, options.testURL); 48 | 49 | global.__operation__ = `loading URL ${absoluteURL.toString()}`; 50 | 51 | await webDriver.get(absoluteURL); 52 | 53 | global.__operation__ = 'setting class name for body element'; 54 | 55 | /* istanbul ignore next */ 56 | await webDriver.executeScript(() => { 57 | document.body.className = 'jest'; 58 | }); 59 | 60 | const proxies = createProxies(webDriver); 61 | 62 | global.webDriverBridge = registerProxies(webDriver, proxies); 63 | 64 | global.__operation__ = 'waiting for the bridge to ready'; 65 | 66 | // Wait until the page is loaded. This will generate a better errors. 67 | await expect(proxies.host.readyPromise).resolves.toBeUndefined(); 68 | 69 | global.__operation__ = 'running test code'; 70 | 71 | // Wait until test call done() or errored out. 72 | await proxies.host.donePromise; 73 | 74 | global.__operation__ = 'retrieving code coverage'; 75 | 76 | const postCoverage = await webDriver.executeScript(() => window.__coverage__); 77 | 78 | // Merge code coverage result. 79 | global.__coverage__ = mergeCoverageMap(global.__coverage__, postCoverage); 80 | global.__operation__ = undefined; 81 | } catch (err) { 82 | try { 83 | const filename = join(tmpdir(), basename(global.jasmine.testPath, '.js') + '.png'); 84 | 85 | writeFile(filename, Buffer.from(await webDriver.takeScreenshot(), 'base64')); 86 | 87 | err.message += `\nSee screenshot for details: ${filename}\n`; 88 | 89 | // eslint-disable-next-line no-empty 90 | } catch (err) {} 91 | 92 | throw err; 93 | } finally { 94 | // After the done.promise is resolved or rejected, before terminating the Web Driver session, we need to wait a bit longer for the RPC callback to complete. 95 | // Otherwise, the RPC return call will throw "NoSuchSessionError" because the session was killed. 96 | 97 | // eslint-disable-next-line no-magic-numbers 98 | await sleep(100); 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /packages/test-harness/src/host/jest/setupToMatchImageSnapshot.js: -------------------------------------------------------------------------------- 1 | const { configureToMatchImageSnapshot } = require('jest-image-snapshot'); 2 | 3 | global.expect && 4 | global.expect.extend({ 5 | toMatchImageSnapshot: configureToMatchImageSnapshot({ 6 | customDiffConfig: { 7 | threshold: 0.14 8 | }, 9 | noColors: true 10 | }) 11 | }); 12 | -------------------------------------------------------------------------------- /samples/context.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Using react-scroll-to-bottom via UMD 5 | 6 | 7 | 8 | 9 | 27 | 28 | 29 |
30 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /samples/recomposition.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Using react-scroll-to-bottom via UMD 5 | 9 | 13 | 17 | 18 | 35 | 36 | 37 |
38 | 270 | 271 | 272 | --------------------------------------------------------------------------------