├── .all-contributorsrc ├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1.bug.md │ ├── 2.feature.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── scripts │ ├── env.sh │ └── release-notes.sh └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .lintstagedrc.js ├── .node-version ├── .prettierignore ├── .prettierrc.js ├── .proxyrc ├── .well-known └── dnt-policy.txt ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MAINTAINING.md ├── README.md ├── ROADMAP.md ├── _redirects ├── cypress.json ├── cypress ├── e2e │ ├── header.spec.js │ └── initApp.spec.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── devtools └── src │ ├── background │ └── background.js │ ├── content-script │ ├── content-script.js │ ├── highlighter │ │ ├── Highlighter.js │ │ ├── Overlay.js │ │ ├── index.js │ │ └── utils.js │ └── lib │ │ ├── inject.js │ │ └── onDocReady.js │ ├── devtools │ ├── components │ │ ├── Icons │ │ │ ├── InspectIcon.js │ │ │ ├── LayersIcon.js │ │ │ ├── LogIcon.js │ │ │ ├── SelectIcon.js │ │ │ └── SettingsIcon.js │ │ └── MenuBar.js │ ├── lib │ │ ├── inspectedWindow.js │ │ ├── settings.js │ │ └── utils.js │ ├── main.html │ ├── main.js │ ├── pane.html │ ├── pane.js │ ├── panel.html │ └── panel.js │ ├── manifest.json │ └── window │ └── testing-library.js ├── docs ├── features.md ├── icon.png └── testing-playground-com.gif ├── logo.svg ├── netlify.toml ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── browserconfig.xml ├── code_thinking.png ├── favicon.ico ├── icon.png ├── icons │ ├── 114-production.png │ ├── 120-production.png │ ├── 128-production.png │ ├── 144-production.png │ ├── 150-production.png │ ├── 152-production.png │ ├── 16-production.png │ ├── 180-production.png │ ├── 192-production.png │ ├── 310-production.png │ ├── 32-production.png │ ├── 36-production.png │ ├── 48-production.png │ ├── 57-production.png │ ├── 60-production.png │ ├── 70-production.png │ ├── 72-production.png │ ├── 76-production.png │ └── 96-production.png ├── manifest.json └── site.jpg ├── scripts ├── build-extension.mts ├── build-lambda.mts ├── changelog.js ├── crxmake.sh ├── utils.mts └── vite.config.mts ├── src ├── App.js ├── components │ ├── CopyButton.js │ ├── CopyButton.test.js │ ├── DomEvents.js │ ├── Editor.js │ ├── Embed.js │ ├── EmptyPane.js │ ├── ErrorBox.js │ ├── Expandable.js │ ├── Header.js │ ├── IconButton.js │ ├── Input.js │ ├── Layout.js │ ├── Loader.js │ ├── MarkupEditor.js │ ├── Menu.js │ ├── Modal.js │ ├── PlaygroundPanels.js │ ├── Preview.js │ ├── PreviewHint.js │ ├── Query.js │ ├── QueryEditor.js │ ├── QueryOutput.js │ ├── Quote.js │ ├── Result.js │ ├── Result.test.js │ ├── ResultQueries.js │ ├── ResultSuggestion.js │ ├── Scrollable.js │ ├── Settings.js │ ├── Share.js │ ├── Spinner.js │ ├── StickyList.js │ └── TabButton.js ├── constants.js ├── context │ └── PreviewEvents.js ├── embed.html ├── embed.js ├── gh-api │ └── gist.js ├── hooks │ ├── useParentMessaging.js │ ├── usePlayground.js │ ├── useSorter.js │ └── useSorter.test.js ├── images │ └── EmptyStreetImg.js ├── index.html ├── index.js ├── lambda │ ├── gist │ │ └── gist.js │ ├── oembed │ │ └── oembed.js │ └── server │ │ └── server.js ├── lib │ ├── beautify.js │ ├── cssPath.js │ ├── deepEqual.js │ ├── domEvents.js │ ├── ensureArray.js │ ├── getFieldName.js │ ├── index.js │ ├── logger.js │ ├── postMessage.js │ ├── queryAdvise.js │ ├── queryAdvise.test.js │ └── state │ │ └── url.js ├── pages │ ├── Embedded.js │ ├── Playground.js │ └── Playground.test.js ├── parser.js ├── sandbox.html ├── sandbox.js ├── service-worker.js └── styles │ ├── app.css │ ├── codemirror.css │ ├── index.css │ ├── spinner.css │ └── toggle.css ├── tailwind.config.js ├── tests └── setupTests.js ├── tsconfig.json └── vite.config.ts /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "testing-playground", 3 | "projectOwner": "testing-library", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "smeijer", 15 | "name": "Stephan Meijer", 16 | "avatar_url": "https://avatars1.githubusercontent.com/u/1196524?v=4", 17 | "profile": "https://github.com/smeijer", 18 | "contributions": [ 19 | "ideas", 20 | "code", 21 | "infra", 22 | "maintenance" 23 | ] 24 | }, 25 | { 26 | "login": "marcosvega91", 27 | "name": "Marco Moretti", 28 | "avatar_url": "https://avatars2.githubusercontent.com/u/5365582?v=4", 29 | "profile": "https://github.com/marcosvega91", 30 | "contributions": [ 31 | "code", 32 | "test", 33 | "doc" 34 | ] 35 | }, 36 | { 37 | "login": "timdeschryver", 38 | "name": "Tim Deschryver", 39 | "avatar_url": "https://avatars1.githubusercontent.com/u/28659384?v=4", 40 | "profile": "http://timdeschryver.dev", 41 | "contributions": [ 42 | "code" 43 | ] 44 | }, 45 | { 46 | "login": "kentcdodds", 47 | "name": "Kent C. Dodds", 48 | "avatar_url": "https://avatars0.githubusercontent.com/u/1500684?v=4", 49 | "profile": "https://kentcdodds.com", 50 | "contributions": [ 51 | "ideas" 52 | ] 53 | }, 54 | { 55 | "login": "MichaelDeBoey", 56 | "name": "Michaël De Boey", 57 | "avatar_url": "https://avatars3.githubusercontent.com/u/6643991?v=4", 58 | "profile": "https://michaeldeboey.be", 59 | "contributions": [ 60 | "code" 61 | ] 62 | }, 63 | { 64 | "login": "delca85", 65 | "name": "Bianca Del Carretto", 66 | "avatar_url": "https://avatars1.githubusercontent.com/u/4076043?v=4", 67 | "profile": "https://github.com/delca85", 68 | "contributions": [ 69 | "code", 70 | "doc" 71 | ] 72 | }, 73 | { 74 | "login": "ljosberinn", 75 | "name": "Gerrit Alex", 76 | "avatar_url": "https://avatars1.githubusercontent.com/u/29307652?v=4", 77 | "profile": "http://gerritalex.de", 78 | "contributions": [ 79 | "code", 80 | "test" 81 | ] 82 | }, 83 | { 84 | "login": "Siemko", 85 | "name": "Dominik Guzy", 86 | "avatar_url": "https://avatars1.githubusercontent.com/u/9118764?v=4", 87 | "profile": "https://www.guzy.dev", 88 | "contributions": [ 89 | "code" 90 | ] 91 | }, 92 | { 93 | "login": "connorProgrammes", 94 | "name": "ConnorProgrammes", 95 | "avatar_url": "https://avatars3.githubusercontent.com/u/66570218?v=4", 96 | "profile": "https://github.com/connorProgrammes", 97 | "contributions": [ 98 | "doc", 99 | "code" 100 | ] 101 | }, 102 | { 103 | "login": "JacobMGEvans", 104 | "name": "Jacob M-G Evans", 105 | "avatar_url": "https://avatars1.githubusercontent.com/u/27247160?v=4", 106 | "profile": "https://twitter.com/JacobMGEvans", 107 | "contributions": [ 108 | "code", 109 | "test" 110 | ] 111 | }, 112 | { 113 | "login": "sumeesh879", 114 | "name": "Sumeesh Nagisetty", 115 | "avatar_url": "https://avatars1.githubusercontent.com/u/20029120?v=4", 116 | "profile": "https://github.com/sumeesh879", 117 | "contributions": [ 118 | "review" 119 | ] 120 | }, 121 | { 122 | "login": "flaviohenriquecbc", 123 | "name": "Flávio H Freitas", 124 | "avatar_url": "https://avatars0.githubusercontent.com/u/1553609?v=4", 125 | "profile": "http://www.linkedin.com/in/flaviohfreitas", 126 | "contributions": [ 127 | "code" 128 | ] 129 | }, 130 | { 131 | "login": "bmeverett", 132 | "name": "Brandon Everett", 133 | "avatar_url": "https://avatars2.githubusercontent.com/u/3941006?v=4", 134 | "profile": "https://github.com/bmeverett", 135 | "contributions": [ 136 | "code" 137 | ] 138 | }, 139 | { 140 | "login": "michal-kocarek", 141 | "name": "Michal Kočárek", 142 | "avatar_url": "https://avatars1.githubusercontent.com/u/762095?v=4", 143 | "profile": "http://brainbox.cz/", 144 | "contributions": [ 145 | "code", 146 | "ideas" 147 | ] 148 | }, 149 | { 150 | "login": "aganglada", 151 | "name": "Alejandro Garcia Anglada", 152 | "avatar_url": "https://avatars0.githubusercontent.com/u/922348?v=4", 153 | "profile": "https://aganglada.com", 154 | "contributions": [ 155 | "code", 156 | "doc", 157 | "test" 158 | ] 159 | }, 160 | { 161 | "login": "ddehart", 162 | "name": "ddehart", 163 | "avatar_url": "https://avatars3.githubusercontent.com/u/901215?v=4", 164 | "profile": "https://github.com/ddehart", 165 | "contributions": [ 166 | "code", 167 | "test" 168 | ] 169 | }, 170 | { 171 | "login": "balavishnuvj", 172 | "name": "Balavishnu V J", 173 | "avatar_url": "https://avatars3.githubusercontent.com/u/13718688?v=4", 174 | "profile": "https://balavishnuvj.com/?utm_source=github", 175 | "contributions": [ 176 | "code" 177 | ] 178 | } 179 | ], 180 | "contributorsPerLine": 7, 181 | "skipCi": true 182 | } 183 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react", "@babel/preset-env"], 3 | "plugins": [ 4 | [ 5 | "@babel/plugin-proposal-class-properties", 6 | { 7 | "loose": true 8 | } 9 | ] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .cache 2 | dist 3 | cypress -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignorePatterns: ['!**/*'], 3 | parser: '@typescript-eslint/parser', 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:prettier/recommended', 7 | 'plugin:react/recommended', 8 | 'plugin:react-hooks/recommended', 9 | 'plugin:cypress/recommended', 10 | ], 11 | plugins: ['vitest-globals'], 12 | parserOptions: { 13 | ecmaVersion: 2020, 14 | sourceType: 'module', 15 | }, 16 | settings: { 17 | react: { 18 | version: 'detect', 19 | }, 20 | }, 21 | env: { 22 | browser: true, 23 | node: true, 24 | es6: true, 25 | 'vitest-globals/env': true, 26 | }, 27 | rules: { 28 | 'arrow-body-style': ['error', 'as-needed'], 29 | curly: 'error', 30 | 31 | // I'll probably add some typescript types instead 32 | 'react/prop-types': 'off', 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [smeijer] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1.bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 🐛 3 | about: Create a bug report for Testing Playground. 4 | labels: bug 5 | --- 6 | 7 | 20 | 21 | ## Bug Report 🐛 22 | 23 | 27 | 28 | ## To Reproduce ✔️ 29 | 30 | 38 | 39 | 1. ...step1 40 | 2. ...step2 41 | 3. ...step3 42 | 43 | ## Expected behavior 🤔 44 | 45 | 48 | 49 | ## Suggested solution 🔦 50 | 51 | 55 | 56 | ## Your Environment 💻 57 | 58 | - _browser_: Chrome, Firefox 59 | - _os_: Mac, Windows, Linux 60 | - _any other relevant information_ 61 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2.feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 💄 3 | about: Suggest a new idea for Testing Playground. 4 | labels: feature 5 | --- 6 | 7 | 18 | 19 | ## Summary 💡 20 | 21 | 22 | 23 | ## Examples 🌈 24 | 25 | 28 | 29 | ## Motivation 🔦 30 | 31 | 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | **What**: 20 | 21 | 22 | 23 | **Why**: 24 | 25 | 26 | 27 | **How**: 28 | 29 | 30 | 31 | **Checklist**: 32 | 33 | 34 | 35 | 36 | 37 | - [ ] Tests 38 | - [ ] Ready to be merged 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /.github/scripts/env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # We don't want the `tip` tag to affect our changelog, and also don't want it to be used for the nighly version 5 | git tag -d tip &> /dev/null || : 6 | 7 | # PUSHING A TAG TRIGGERS A VERSIONED RELEASE, ANY OTHER PUSH TRIGGERS A NIGHTLY 8 | REF=$(echo "$GITHUB" | jq -r '.ref') 9 | VERSION=$(echo "${REF#refs/tags/}") 10 | 11 | # export some data 12 | set-env () { 13 | echo "$1=$2" >> $GITHUB_ENV 14 | export $1="$2" 15 | } 16 | 17 | set-env "VERSION" "$VERSION" -------------------------------------------------------------------------------- /.github/scripts/release-notes.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | echo "PATH: $(dirname $(realpath $0))" 5 | 6 | : "${ESCAPE:=${GITHUB_ACTIONS:-false}}" 7 | 8 | # generate release notes 9 | CHANGELOG="$(npm run --silent ci:changelog)" 10 | 11 | # escape newlines for github actions 12 | if [ "$ESCAPE" != false ]; then 13 | echo "escape it" 14 | CHANGELOG="${CHANGELOG//'%'/'%25'}" 15 | CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" 16 | CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" 17 | fi 18 | 19 | # export some data 20 | echo "::set-output name=changelog::$CHANGELOG" 21 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | env: 9 | APP_NAME: Testing Playground 10 | NODE_VERSION: '18.x' 11 | 12 | jobs: 13 | create-release: 14 | name: Create Release Notes 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 15 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Fetch all tags 24 | run: git fetch origin +refs/tags/*:refs/tags/* 25 | 26 | - uses: actions/setup-node@v1 27 | with: 28 | node-version: '${{ env.NODE_VERSION }}' 29 | 30 | - name: Cache node modules 31 | id: cache 32 | uses: actions/cache@v1 33 | with: 34 | path: node_modules 35 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }} 36 | 37 | - name: Install Dependencies 38 | if: steps.cache.outputs.cache-hit != 'true' 39 | run: npm ci 40 | - name: Setup Env 41 | run: | 42 | find .github/scripts -type f -iname "*.sh" -exec chmod +x {} \; 43 | .github/scripts/env.sh 44 | env: 45 | GITHUB: ${{ toJson(github) }} 46 | 47 | - name: Create Release Notes 48 | id: release_notes 49 | run: .github/scripts/release-notes.sh 50 | 51 | - name: Create Release 52 | id: create_release 53 | uses: actions/create-release@v1 54 | with: 55 | tag_name: ${{ github.ref }} 56 | release_name: ${{ env.APP_NAME }} ${{ env.VERSION }} 57 | body: ${{ steps.release_notes.outputs.changelog }} 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} 60 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | 7 | env: 8 | NODE_VERSION: '18.x' 9 | 10 | jobs: 11 | setup: 12 | name: Setup 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 5 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - uses: actions/setup-node@v1 19 | with: 20 | node-version: '${{ env.NODE_VERSION }}' 21 | 22 | - name: Cache node modules 23 | id: cache 24 | uses: actions/cache@v1 25 | with: 26 | path: node_modules 27 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }} 28 | 29 | - name: Install Dependencies 30 | if: steps.cache.outputs.cache-hit != 'true' 31 | run: npm ci 32 | 33 | eslint: 34 | name: Eslint 35 | runs-on: ubuntu-latest 36 | needs: [setup] 37 | timeout-minutes: 5 38 | steps: 39 | - uses: actions/checkout@v2 40 | with: 41 | fetch-depth: 0 42 | 43 | - uses: actions/setup-node@v1 44 | with: 45 | node-version: '${{ env.NODE_VERSION }}' 46 | 47 | - name: Fetch all branches 48 | run: | 49 | git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* 50 | 51 | - name: Cache node modules 52 | uses: actions/cache@v1 53 | with: 54 | path: node_modules 55 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }} 56 | 57 | - name: Run Eslint 58 | run: npm run ci:lint -- $(git diff --diff-filter d --name-only origin/${{ github.base_ref }}...HEAD -- '*.js' '*.ts' '*.tsx') 59 | 60 | unit-tests: 61 | name: Unit Tests 62 | runs-on: ubuntu-latest 63 | needs: [setup] 64 | timeout-minutes: 5 65 | steps: 66 | - uses: actions/checkout@v2 67 | 68 | - uses: actions/setup-node@v1 69 | with: 70 | node-version: '${{ env.NODE_VERSION }}' 71 | 72 | - name: Cache node modules 73 | uses: actions/cache@v1 74 | with: 75 | path: node_modules 76 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }} 77 | 78 | - name: Run Tests 79 | run: npm test 80 | e2e-tests: 81 | name: e2e Tests 82 | runs-on: ubuntu-latest 83 | needs: [setup] 84 | if: false # temporarily disabled 85 | steps: 86 | - name: Checkout 87 | uses: actions/checkout@v2 88 | 89 | - name: Cypress run 90 | uses: cypress-io/github-action@v2 91 | with: 92 | start: npm start 93 | wait-on: http://localhost:5173 94 | command: npm run cypress:run 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cypress/videos 2 | dist/ 3 | node_modules/ 4 | 5 | # Local Netlify folder 6 | .netlify 7 | .env 8 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '**/*.js': (files) => [ 3 | `eslint --quiet --fix ${files.join(' ')}`, 4 | `vitest related --run`, 5 | ], 6 | '**/*.{md,js,json,yml,html,css}': (files) => [ 7 | `prettier --write ${files.join(' ')}`, 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18.9.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | dist 3 | cypress -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 80, 6 | tabWidth: 2, 7 | overrides: [ 8 | { 9 | files: '*.html', 10 | options: { 11 | printWidth: 120, 12 | }, 13 | }, 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /.proxyrc: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://localhost:8888/" 4 | } 5 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at stephan.meijer+coc@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | **Working on your first Pull Request?** You can learn how from this _free_ 6 | series [How to Contribute to an Open Source Project on GitHub][egghead] 7 | 8 | ## Project setup 9 | 10 | 1. Fork and clone the repo 11 | 2. Run `npm ci` to install dependencies 12 | 3. Create a branch for your PR with `git checkout -b feature/your-branch-name` 13 | 14 | > Tip: Keep your `develop` branch pointing at the original repository and make 15 | > pull requests from branches on your fork. To do this, run: 16 | > 17 | > ``` 18 | > git remote add upstream git@github.com:testing-library/testing-playground.git 19 | > git fetch upstream 20 | > git branch --set-upstream-to=upstream/develop develop 21 | > ``` 22 | > 23 | > This will add the original repository as a "remote" called "upstream," Then 24 | > fetch the git information from that remote, then set your local `develop` 25 | > branch to use the upstream develop branch whenever you run `git pull`. Then you 26 | > can make all of your pull request branches based on this `develop` branch. 27 | > Whenever you want to update your version of `develop`, do a regular `git pull`. 28 | 29 | ## Committing and Pushing changes 30 | 31 | Please make sure to run the tests before you commit your changes. You can run 32 | `npm run test` to do so. 33 | 34 | ### Linting with git hooks 35 | 36 | There are git hooks set up with this project that are automatically installed 37 | when you install dependencies. They're really handy, and will take care of linting 38 | and formatting for you. 39 | 40 | ## Thinking of a new feature? 41 | 42 | Make sure you read our [features guidelines](/docs/features.md). 43 | 44 | ## Help needed 45 | 46 | Please checkout the [the open issues][issues] 47 | 48 | Also, please watch the repo and respond to questions/bug reports/feature 49 | requests! Thanks! 50 | 51 | [egghead]: https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github 52 | [issues]: https://github.com/testing-library/testing-playground/issues 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stephan Meijer 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 | -------------------------------------------------------------------------------- /MAINTAINING.md: -------------------------------------------------------------------------------- 1 | # Maintaining 2 | 3 | This is documentation for maintainers of this project. 4 | 5 | ## Code of Conduct 6 | 7 | Please review, understand, and be an example of it. Violations of the code of 8 | conduct are taken seriously, even (especially) for maintainers. 9 | 10 | ## Issues 11 | 12 | We want to support and build the community. We do that best by helping people 13 | learn to solve their own problems. We have an issue template and hopefully most 14 | folks follow it. If it's not clear what the issue is, invite them to create a 15 | minimal reproduction of what they're trying to accomplish or the bug they think 16 | they've found. 17 | 18 | Once it's determined that a code change is necessary, point people to 19 | [makeapullrequest.com](http://makeapullrequest.com) and invite them to make a 20 | pull request. If they're the one who needs the feature, they're the one who can 21 | build it. If they need some hand holding and you have time to lend a hand, 22 | please do so. It's an investment into another human being, and an investment 23 | into a potential maintainer. 24 | 25 | Remember that this is open source, so the code is not yours, it's ours. If 26 | someone needs a change in the codebase, you don't have to make it happen 27 | yourself. Commit as much time to the project as you want/need to. Nobody can ask 28 | any more of you than that. 29 | 30 | ## Pull Requests 31 | 32 | As a maintainer, you're fine to make your branches on the main repo or on your 33 | own fork. Either way is fine. 34 | 35 | When we receive a pull request, a various automated checks are kicked off 36 | automatically. We avoid merging anything that doesn't pass these checks. 37 | 38 | Please review PRs and focus on the code rather than the individual. You never 39 | know when this is someone's first ever PR and we want their experience to be as 40 | positive as possible, so be uplifting and constructive. 41 | 42 | When you merge the pull request, 99% of the time you should use the 43 | [Squash and merge](https://help.github.com/articles/merging-a-pull-request/) 44 | feature. This keeps our git history clean, but more importantly, this allows us 45 | to make any necessary changes to the commit message so we release what we want 46 | to release. See the next section on Releases for more about that. 47 | 48 | ## Release 49 | 50 | Our releases are automatic. They happen whenever code lands into `live`. A 51 | netlify build gets kicked off, and when tagged a changelog is published on 52 | GitHub. The changelog is generated based on the git commit messages. With this 53 | in mind, **please brush up on [the commit message convention][commit] which 54 | drives our releases.** 55 | 56 | > One important note about this: Please make sure that commit messages do NOT 57 | > contain the words "BREAKING CHANGE" in them unless we want to push a major 58 | > version. I've been burned by this more than once where someone will include 59 | > "BREAKING CHANGE: None" and it will end up releasing a new major version. Not 60 | > a huge deal honestly, but kind of annoying... 61 | 62 | ## Thanks! 63 | 64 | Thank you so much for helping to maintain this project! 65 | 66 | [commit]: https://github.com/conventional-changelog-archived-repos/conventional-changelog-angular/blob/ed32559941719a130bb0327f886d6a32a8cbc2ba/convention.md 67 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | ## Interactive HTML Preview 4 | 5 | _Status: Shipped at May 23, 2020 ([#2](https://github.com/testing-library/testing-playground/issues/2))_ 6 | 7 | The html preview tab should be made interactive. In such a way that one can hover elements and see the recommended query. When clicking on the link, the query should applied to the editor, like the various query buttons do. 8 | 9 | ## Update Readme.md 10 | 11 | _Status: Shipped at May 25, 2020 ([#23](https://github.com/testing-library/testing-playground/pull/23))_ 12 | 13 | We don't have any instructions on how to get the thing started at localhost, or how to contribute. This should be made crystal clear. 14 | 15 | _Status: Shipped at May 24, 2020 ([#14](https://github.com/testing-library/testing-playground/issues/4))_ 16 | 17 | Also, I have no idea how it works, but I know we need to use emoji-key to list contributors in the readme. Is that an automated thing? 18 | 19 | ## Add tests 20 | 21 | _Status: Ongoing ([#7](https://github.com/testing-library/testing-playground/issues/7))_ 22 | 23 | A [testing-playground.com] without tests... I don't think that should be a thing, but at this moment it is. 24 | 25 | We should fix that, and use Vitest & testing-library to do it. 26 | 27 | ## Usage instructions 28 | 29 | _Status: In backlog ([#8](https://github.com/testing-library/testing-playground/issues/8))_ 30 | 31 | Do we need some instructions on how to use the tool? I'm not sure about this yet, but if we do, it should be added to the playground itself. Not just a markdown on github. 32 | 33 | ## Embeddable 34 | 35 | _Status: Shipped at May 28, 2020 ([#9](https://github.com/testing-library/testing-playground/issues/9))_ 36 | 37 | How awesome would it be if we had an embed-mode, so users can embed the playground in the blogs they write about testing / testing-library? 38 | 39 | ## Support User-Event 40 | 41 | _Status: In backlog ([#10](https://github.com/testing-library/testing-playground/issues/10))_ 42 | 43 | I haven't worked out the details, but enabling users to play with [user-event]s would awesome. Perhaps together with adding support for [HTML mixed mode]? 44 | 45 | ## DevTools! 46 | 47 | _Status: In progress ([#11](https://github.com/testing-library/testing-playground/issues/11))_ 48 | 49 | I believe we can wrap this project into a chrome extension. That way people can use the thing on their own sites, without needing to copy / paste html fragments. How awesome would that be?! 50 | 51 | [testing-playground.com]: https://testing-playground.com 52 | [html mixed mode]: https://codemirror.net/mode/htmlmixed/index.html 53 | [user-event]: https://testing-library.com/docs/ecosystem-user-event 54 | -------------------------------------------------------------------------------- /_redirects: -------------------------------------------------------------------------------- 1 | /oembed /.netlify/functions/oembed 200! 2 | /api/* /.netlify/functions/:splat 200! 3 | / /.netlify/functions/server 200! 4 | /* /.netlify/functions/server 200 5 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:5173", 3 | "integrationFolder": "cypress/e2e" 4 | } 5 | -------------------------------------------------------------------------------- /cypress/e2e/header.spec.js: -------------------------------------------------------------------------------- 1 | it('renders Testing Playground Header', () => { 2 | cy.visit('/'); 3 | 4 | cy.findByRole('heading', { 5 | level: 1, 6 | name: 'Testing Playground mascot Froggy ️ Testing Playground', 7 | }).should('exist'); 8 | 9 | cy.findByRole('link', { 10 | name: 'Testing Playground mascot Froggy ️ Testing Playground', 11 | }) 12 | .should('exist') 13 | .and('have.attr', 'href', '/'); 14 | 15 | cy.findByRole('button', { name: /playground/i }).should('exist'); 16 | cy.findByRole('button', { name: /run/i }).should('exist'); 17 | cy.findByRole('button', { name: /settings/i }).should('exist'); 18 | cy.findByRole('button', { name: /more info/i }).should('exist'); 19 | 20 | cy.findByRole('button', { name: /playground/i }).click(); 21 | // playground menu shows 22 | cy.findByRole('menuitem', { name: /new/i }).should('exist'); 23 | cy.findByRole('menuitem', { name: /save/i }).should('exist'); 24 | cy.findByRole('menuitem', { name: /fork/i }).should('exist'); 25 | cy.findByRole('menuitem', { name: /share/i }).should('exist'); 26 | cy.findByRole('menuitem', { name: /embed/i }).should('exist'); 27 | 28 | cy.findByRole('button', { name: /settings/i }).click(); 29 | // previous menu disappear 30 | cy.findByRole('menuitem', { name: /new/i }).should('not.exist'); 31 | cy.findByRole('menuitem', { name: /save/i }).should('not.exist'); 32 | cy.findByRole('menuitem', { name: /fork/i }).should('not.exist'); 33 | cy.findByRole('menuitem', { name: /share/i }).should('not.exist'); 34 | cy.findByRole('menuitem', { name: /embed/i }).should('not.exist'); 35 | 36 | // settings menu shows 37 | cy.findByLabelText(/auto-run code/i).should('exist'); 38 | cy.findByLabelText(/test-id attribute:/i).should('exist'); 39 | 40 | cy.findByRole('button', { name: /more info/i }).click(); 41 | // previous menu disappear 42 | cy.findByLabelText(/auto-run code/i).should('not.be.visible'); 43 | cy.findByLabelText(/test-id attribute:/i).should('not.be.visible'); 44 | 45 | // more info menu shows 46 | cy.findByRole('menuitem', { name: /github/i }).should('exist').and('have.attr', 'href', 'https://github.com/testing-library/testing-playground/issues'); 47 | cy.findByRole('menuitem', { name: /support us/i }).should('exist').and('have.attr', 'href', 'https://github.com/sponsors/smeijer'); 48 | cy.findByRole('menuitem', { name: /twitter/i }).should('exist').and('have.attr', 'href', 'https://twitter.com/meijer_s'); 49 | cy.findByRole('menuitem', { name: /chrome extension/i }).should('exist').and('have.attr', 'href', 'https://chrome.google.com/webstore/detail/testing-playground/hejbmebodbijjdhflfknehhcgaklhano'); 50 | cy.findByRole('menuitem', { name: /introduction/i }).should('exist').and('have.attr', 'href', 'https://testing-library.com/docs/dom-testing-library/intro'); 51 | cy.findByRole('menuitem', { name: /query priority/i }).should('exist').and('have.attr', 'href', 'https://testing-library.com/docs/guide-which-query'); 52 | cy.findByRole('menuitem', { name: /common mistakes/i }).should('exist').and('have.attr', 'href', 'https://kentcdodds.com/blog/common-mistakes-with-react-testing-library'); 53 | 54 | // click outside 55 | cy.findByRole('button', { name: /more info/i }).parent().click(); 56 | // previous menu disappear 57 | cy.findByRole('menuitem', { name: /github/i }).should('not.exist'); 58 | cy.findByRole('menuitem', { name: /support us/i }).should('not.exist'); 59 | cy.findByRole('menuitem', { name: /twitter/i }).should('not.exist'); 60 | cy.findByRole('menuitem', { name: /chrome extension/i }).should('not.exist'); 61 | cy.findByRole('menuitem', { name: /introduction/i }).should('not.exist'); 62 | cy.findByRole('menuitem', { name: /query priority/i }).should('not.exist'); 63 | cy.findByRole('menuitem', { name: /common mistakes/i }).should('not.exist'); 64 | }); 65 | -------------------------------------------------------------------------------- /cypress/e2e/initApp.spec.js: -------------------------------------------------------------------------------- 1 | it('renders Testing Playground with an example', () => { 2 | cy.visit('/'); 3 | 4 | cy.getMarkupEditor().should('exist'); 5 | 6 | cy.getSandboxBody().within(() => { 7 | cy.findByLabelText('Email address') 8 | .should('exist') 9 | .and('have.attr', 'type', 'email') 10 | .and('have.attr', 'placeholder', 'Enter email'); 11 | 12 | cy.findByText("It's safe with us. We hate spam!").should('exist'); 13 | 14 | cy.findByLabelText('Password') 15 | .should('exist') 16 | .and('have.attr', 'type', 'password') 17 | .and('have.attr', 'placeholder', 'Password'); 18 | 19 | cy.findByLabelText('I accept the terms and conditions') 20 | .should('exist') 21 | .and('have.attr', 'type', 'checkbox') 22 | .and('not.be.checked'); 23 | 24 | cy.findByRole('link', { name: 'terms and conditions' }) 25 | .should('exist') 26 | .and('have.attr', 'href', 'https://www.example.com'); 27 | 28 | cy.findByRole('button', { name: 'Submit' }).should('exist'); 29 | }); 30 | 31 | cy.findByRole('region', { name: 'html preview' }).within(() => { 32 | cy.findByRole('button', { name: 'expand' }).should('exist'); 33 | cy.findByText('accessible roles:').should('exist'); 34 | cy.findByText('generic').should('exist'); 35 | }); 36 | 37 | cy.getQueryEditor().should('exist'); 38 | 39 | cy.findByRole('region', { name: 'query suggestion' }).within(() => { 40 | cy.findByText('> ').should('exist'); 41 | cy.findByRole('button', { name: 'expand' }).should('exist'); 42 | }); 43 | 44 | cy.getResult().within(() => { 45 | cy.findByText('suggested query').should('exist'); 46 | 47 | cy.findByText("> getByRole('button', { name: /submit/i })").should('exist'); 48 | 49 | cy.findByRole('button', { name: 'copy query' }).should('exist'); 50 | 51 | cy.findByText( 52 | 'There is one thing though. You could make the query a bit more specific by adding the name option.', 53 | ).should('exist'); 54 | 55 | cy.findByRole('heading', { 56 | level: 3, 57 | name: '1. Queries Accessible to Everyone', 58 | }).should('exist'); 59 | 60 | cy.findByRole('heading', { 61 | level: 3, 62 | name: '2. Semantic Queries', 63 | }).should('exist'); 64 | 65 | cy.findByRole('heading', { 66 | level: 3, 67 | name: '3. Test IDs', 68 | }).should('exist'); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/cypress/add-commands'; 2 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Add common command to get the body of the sandbox iframe 20 | // See: https://www.cypress.io/blog/2020/02/12/working-with-iframes-in-cypress/ 21 | Cypress.Commands.add('getSandboxBody', () => { 22 | cy.log('getSandboxBody'); 23 | 24 | return cy 25 | .findByTitle('sandbox') 26 | .its('0.contentDocument.body', { log: false }) 27 | .should('not.be.empty') 28 | .then((body) => cy.wrap(body, { log: false })); 29 | }); 30 | 31 | // Add commands to clear CodeMirror editors 32 | // See: https://stackoverflow.com/questions/62012319/how-can-i-clear-a-codemirror-editor-field-from-cypress 33 | 34 | Cypress.Commands.add('clearMarkupEditor', () => { 35 | cy.log('clearMarkupEditor'); 36 | 37 | cy.get('.CodeMirror', { log: false }) 38 | .first({ log: false }) 39 | .its('0.CodeMirror', { log: false }) 40 | .then((editor) => { 41 | editor.setValue(''); 42 | }); 43 | }); 44 | 45 | Cypress.Commands.add('clearQueryEditor', () => { 46 | cy.log('clearQueryEditor'); 47 | 48 | cy.get('.CodeMirror', { log: false }) 49 | .last({ log: false }) 50 | .its('0.CodeMirror', { log: false }) 51 | .then((editor) => { 52 | editor.setValue(''); 53 | }); 54 | }); 55 | 56 | Cypress.Commands.add('getMarkupEditor', () => { 57 | cy.log('getMarkupEditor'); 58 | 59 | return cy.get('.CodeMirror textarea', { log: false }).first({ log: false }); 60 | }); 61 | 62 | Cypress.Commands.add('getQueryEditor', () => { 63 | cy.log('getQueryEditor'); 64 | 65 | return cy.get('.CodeMirror textarea', { log: false }).last({ log: false }); 66 | }); 67 | 68 | Cypress.Commands.add('getResult', () => { 69 | cy.log('getResult'); 70 | 71 | return cy.get('div[data-testid=result]', { log: false }); 72 | }); 73 | -------------------------------------------------------------------------------- /devtools/src/background/background.js: -------------------------------------------------------------------------------- 1 | // This import needs to be here, we don't use the bridge here directly. But 2 | // simply importing crx-bridge, is what creates the messaging proxy. 3 | import 'crx-bridge'; 4 | -------------------------------------------------------------------------------- /devtools/src/content-script/content-script.js: -------------------------------------------------------------------------------- 1 | import Bridge from 'crx-bridge'; 2 | import setupHighlighter from './highlighter'; 3 | 4 | import parser from '../../../src/parser'; 5 | import { getAllPossibleQueries } from '../../../src/lib'; 6 | import inject from './lib/inject'; 7 | import { setup } from '../window/testing-library'; 8 | import onDocReady from './lib/onDocReady'; 9 | 10 | // HACK: mock out console warn for https://github.com/testing-library/testing-playground/issues/330 11 | console.warn = () => undefined; 12 | 13 | function init() { 14 | inject('../window/testing-library.js'); 15 | setup(window); 16 | 17 | window.__TESTING_PLAYGROUND__ = window.__TESTING_PLAYGROUND__ || {}; 18 | const hook = window.__TESTING_PLAYGROUND__; 19 | 20 | hook.highlighter = setupHighlighter({ view: window, onSelectNode }); 21 | 22 | function onSelectNode(node) { 23 | const queries = getAllPossibleQueries( 24 | { 25 | rootNode: document.body, 26 | element: node, 27 | }, 28 | 'DEVTOOLS', 29 | ); 30 | 31 | const suggestion = Object.values(queries).find(Boolean); 32 | 33 | const result = parser.parse( 34 | { 35 | rootNode: document.body, 36 | query: suggestion?.snippet || '', 37 | }, 38 | 'DEVTOOLS', 39 | ); 40 | 41 | Bridge.sendMessage('SELECT_NODE', result, 'devtools'); 42 | } 43 | 44 | Bridge.onMessage('PARSE_QUERY', function ({ data }) { 45 | const result = parser.parse({ 46 | rootNode: document.body, 47 | query: data.query, 48 | }); 49 | 50 | if (data.highlight) { 51 | const selector = result.elements.map((x) => x.cssPath).join(', '); 52 | const nodes = 53 | result.elements.length > 0 54 | ? Array.from(document.body.querySelectorAll(selector)) 55 | : []; 56 | 57 | hook.highlighter.highlight({ 58 | nodes, 59 | hideAfterTimeout: data.hideAfterTimeout ?? 1500, 60 | }); 61 | } 62 | 63 | return { result }; 64 | }); 65 | 66 | Bridge.onMessage('SET_SETTINGS', function ({ data }) { 67 | parser.configure(data); 68 | }); 69 | 70 | // when the selected element is changed by using the element inspector, 71 | // this method will be called from devtools/main.js 72 | hook.onSelectionChanged = function onSelectionChanged(el) { 73 | onSelectNode(el); 74 | }; 75 | } 76 | 77 | onDocReady(init); 78 | -------------------------------------------------------------------------------- /devtools/src/content-script/highlighter/Highlighter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * Copyright (c) 2020, Stephan Meijer 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | **/ 9 | 10 | import Overlay from './Overlay'; 11 | 12 | const SHOW_DURATION = 2000; 13 | 14 | let timeoutID = null; 15 | let overlay = null; 16 | 17 | export function hideOverlay() { 18 | timeoutID = null; 19 | 20 | if (overlay !== null) { 21 | overlay.remove(); 22 | overlay = null; 23 | } 24 | } 25 | 26 | export function showOverlay(elements, hideAfterTimeout) { 27 | if (window.document == null) { 28 | return; 29 | } 30 | 31 | if (timeoutID !== null) { 32 | clearTimeout(timeoutID); 33 | } 34 | 35 | if (elements == null) { 36 | return; 37 | } 38 | 39 | if (overlay === null) { 40 | overlay = new Overlay(); 41 | } 42 | 43 | overlay.inspect(elements); 44 | 45 | if (hideAfterTimeout) { 46 | timeoutID = setTimeout(hideOverlay, SHOW_DURATION); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /devtools/src/content-script/highlighter/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * Copyright (c) 2020, Stephan Meijer 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | **/ 9 | 10 | // Get the window object for the document that a node belongs to, 11 | // or return null if it cannot be found (node not attached to DOM, 12 | // etc). 13 | export function getOwnerWindow(node) { 14 | return node.ownerDocument?.defaultView || null; 15 | } 16 | 17 | // Get the iframe containing a node, or return null if it cannot 18 | // be found (node not within iframe, etc). 19 | export function getOwnerIframe(node) { 20 | return getOwnerWindow(node)?.frameElement || null; 21 | } 22 | 23 | // Get a bounding client rect for a node, with an 24 | // offset added to compensate for its border. 25 | export function getBoundingClientRectWithBorderOffset(node) { 26 | const dimensions = getElementDimensions(node); 27 | 28 | return mergeRectOffsets([ 29 | node.getBoundingClientRect(), 30 | { 31 | top: dimensions.borderTop, 32 | left: dimensions.borderLeft, 33 | bottom: dimensions.borderBottom, 34 | right: dimensions.borderRight, 35 | // This width and height won't get used by mergeRectOffsets (since this 36 | // is not the first rect in the array), but we set them so that this 37 | // object typechecks as a ClientRect. 38 | width: 0, 39 | height: 0, 40 | }, 41 | ]); 42 | } 43 | 44 | // Add together the top, left, bottom, and right properties of 45 | // each ClientRect, but keep the width and height of the first one. 46 | export function mergeRectOffsets(rects) { 47 | return rects.reduce((previousRect, rect) => { 48 | if (previousRect == null) { 49 | return rect; 50 | } 51 | 52 | return { 53 | top: previousRect.top + rect.top, 54 | left: previousRect.left + rect.left, 55 | width: previousRect.width, 56 | height: previousRect.height, 57 | bottom: previousRect.bottom + rect.bottom, 58 | right: previousRect.right + rect.right, 59 | }; 60 | }); 61 | } 62 | 63 | // Calculate a boundingClientRect for a node relative to boundaryWindow, 64 | // taking into account any offsets caused by intermediate iframes. 65 | export function getNestedBoundingClientRect(node, boundaryWindow) { 66 | const ownerIframe = getOwnerIframe(node); 67 | 68 | if (ownerIframe && ownerIframe.contentWindow !== boundaryWindow) { 69 | const rects = [node.getBoundingClientRect()]; 70 | let currentIframe = ownerIframe; 71 | let onlyOneMore = false; 72 | while (currentIframe) { 73 | const rect = getBoundingClientRectWithBorderOffset(currentIframe); 74 | rects.push(rect); 75 | currentIframe = getOwnerIframe(currentIframe); 76 | 77 | if (onlyOneMore) { 78 | break; 79 | } 80 | // We don't want to calculate iframe offsets upwards beyond 81 | // the iframe containing the boundaryWindow, but we 82 | // need to calculate the offset relative to the boundaryWindow. 83 | if (currentIframe && getOwnerWindow(currentIframe) === boundaryWindow) { 84 | onlyOneMore = true; 85 | } 86 | } 87 | 88 | return mergeRectOffsets(rects); 89 | } else { 90 | return node.getBoundingClientRect(); 91 | } 92 | } 93 | 94 | export function getElementDimensions(domElement) { 95 | const calculatedStyle = window.getComputedStyle(domElement); 96 | 97 | return { 98 | borderLeft: parseInt(calculatedStyle.borderLeftWidth, 10), 99 | borderRight: parseInt(calculatedStyle.borderRightWidth, 10), 100 | borderTop: parseInt(calculatedStyle.borderTopWidth, 10), 101 | borderBottom: parseInt(calculatedStyle.borderBottomWidth, 10), 102 | marginLeft: parseInt(calculatedStyle.marginLeft, 10), 103 | marginRight: parseInt(calculatedStyle.marginRight, 10), 104 | marginTop: parseInt(calculatedStyle.marginTop, 10), 105 | marginBottom: parseInt(calculatedStyle.marginBottom, 10), 106 | paddingLeft: parseInt(calculatedStyle.paddingLeft, 10), 107 | paddingRight: parseInt(calculatedStyle.paddingRight, 10), 108 | paddingTop: parseInt(calculatedStyle.paddingTop, 10), 109 | paddingBottom: parseInt(calculatedStyle.paddingBottom, 10), 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /devtools/src/content-script/lib/inject.js: -------------------------------------------------------------------------------- 1 | /* global chrome */ 2 | 3 | function inject(src) { 4 | return new Promise((resolve) => { 5 | const target = document.head || document.documentElement; 6 | 7 | const script = document.createElement('script'); 8 | script.setAttribute('type', 'text/javascript'); 9 | script.setAttribute( 10 | 'src', 11 | src.includes('://') ? src : chrome.runtime.getURL(src), 12 | ); 13 | script.addEventListener('load', () => { 14 | target.removeChild(script); 15 | resolve(); 16 | }); 17 | 18 | target.appendChild(script); 19 | }); 20 | } 21 | 22 | export default inject; 23 | -------------------------------------------------------------------------------- /devtools/src/content-script/lib/onDocReady.js: -------------------------------------------------------------------------------- 1 | function onDocReady(fn) { 2 | if (document.readyState !== 'loading') { 3 | return fn(); 4 | } 5 | 6 | setTimeout(() => { 7 | onDocReady(fn); 8 | }, 9); 9 | } 10 | 11 | export default onDocReady; 12 | -------------------------------------------------------------------------------- /devtools/src/devtools/components/Icons/InspectIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function InspectIcon() { 4 | return ( 5 | 6 | 7 | 11 | 12 | ); 13 | } 14 | 15 | export default InspectIcon; 16 | -------------------------------------------------------------------------------- /devtools/src/devtools/components/Icons/LayersIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function LayersIcon() { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | 14 | export default LayersIcon; 15 | -------------------------------------------------------------------------------- /devtools/src/devtools/components/Icons/LogIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function LogIcon() { 4 | return ( 5 | 6 | 7 | 11 | 12 | ); 13 | } 14 | 15 | export default LogIcon; 16 | -------------------------------------------------------------------------------- /devtools/src/devtools/components/Icons/SelectIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function SelectIcon() { 4 | return ( 5 | 6 | 7 | 11 | 12 | ); 13 | } 14 | 15 | export default SelectIcon; 16 | -------------------------------------------------------------------------------- /devtools/src/devtools/components/Icons/SettingsIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function SettingsIcon() { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | } 13 | 14 | export default SettingsIcon; 15 | -------------------------------------------------------------------------------- /devtools/src/devtools/components/MenuBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Bridge from 'crx-bridge'; 3 | 4 | import inspectedWindow from '../lib/inspectedWindow'; 5 | 6 | // we don't use octicons here, as that style doesn't really fit in devtools 7 | import SelectIcon from './Icons/SelectIcon'; 8 | import LayersIcon from './Icons/LayersIcon'; 9 | import InspectIcon from './Icons/InspectIcon'; 10 | import SettingsIcon from './Icons/SettingsIcon'; 11 | import LogIcon from './Icons/LogIcon'; 12 | import { Menu, MenuButton, MenuPopover } from '../../../../src/components/Menu'; 13 | import Settings from '../../../../src/components/Settings'; 14 | import { getSettings, setSettings } from '../lib/settings'; 15 | 16 | function MenuBar({ cssPath, suggestion }) { 17 | return ( 18 |
19 | 28 | 29 | 38 | 39 |
40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | { 49 | if (type === 'SET_SETTINGS') { 50 | setSettings(data); 51 | } 52 | }} 53 | settings={getSettings()} 54 | /> 55 | 56 | 57 | 58 | 66 | 67 | 75 |
76 | ); 77 | } 78 | 79 | export default MenuBar; 80 | -------------------------------------------------------------------------------- /devtools/src/devtools/lib/inspectedWindow.js: -------------------------------------------------------------------------------- 1 | /* global chrome */ 2 | 3 | // We can't do this with messaging, because in Chrome, eval always runs in the 4 | // context of the ContentScript, not in the context of Window. Maybe we can just 5 | // do something with `useContentScriptContext: true`, maintain a log on the most 6 | // recent(ly) used element(s), assign an id to them, and then use messaging. But 7 | // for now, this is way easier. 8 | 9 | function escape(str) { 10 | return str.replace(/'/g, "\\'").replace(/\n/g, '\\n'); 11 | } 12 | 13 | function logQuery(query) { 14 | chrome.devtools.inspectedWindow.eval(` 15 | console.log('${escape(query)}'); 16 | console.log(eval(${query})); 17 | `); 18 | } 19 | 20 | function inspect(cssPath) { 21 | chrome.devtools.inspectedWindow.eval(` 22 | inspect(document.querySelector('${cssPath}')); 23 | `); 24 | } 25 | 26 | export default { 27 | logQuery, 28 | inspect, 29 | }; 30 | -------------------------------------------------------------------------------- /devtools/src/devtools/lib/settings.js: -------------------------------------------------------------------------------- 1 | import Bridge from 'crx-bridge'; 2 | 3 | const localSettings = navigator.cookieEnabled 4 | ? JSON.parse(localStorage.getItem('playground_settings')) 5 | : {}; 6 | 7 | let _settings = Object.assign( 8 | { 9 | testIdAttribute: 'data-testid', 10 | }, 11 | localSettings, 12 | ); 13 | 14 | Bridge.sendMessage('SET_SETTINGS', _settings, 'content-script'); 15 | 16 | export function getSettings() { 17 | return _settings; 18 | } 19 | 20 | export function setSettings(settings) { 21 | Object.assign(_settings, settings); 22 | Bridge.sendMessage('SET_SETTINGS', _settings, 'content-script'); 23 | if (navigator.cookieEnabled) { 24 | localStorage.setItem('playground_settings', JSON.stringify(_settings)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /devtools/src/devtools/lib/utils.js: -------------------------------------------------------------------------------- 1 | /* global chrome */ 2 | const IS_CHROME = navigator.userAgent.indexOf('Firefox') < 0; 3 | 4 | export function getBrowserName() { 5 | return IS_CHROME ? 'Chrome' : 'Firefox'; 6 | } 7 | 8 | export function getBrowserTheme() { 9 | if (!chrome.devtools || !chrome.devtools.panels) { 10 | return 'light'; 11 | } 12 | 13 | return chrome.devtools.panels.themeName === 'dark' ? 'dark' : 'light'; 14 | } 15 | -------------------------------------------------------------------------------- /devtools/src/devtools/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /devtools/src/devtools/main.js: -------------------------------------------------------------------------------- 1 | /* global chrome */ 2 | import { getBrowserName } from './lib/utils'; 3 | const panels = chrome.devtools.panels; 4 | 5 | const isChrome = getBrowserName() === 'Chrome'; 6 | const name = isChrome ? '🐸 Testing Playground' : 'Testing Playground'; 7 | 8 | panels.create(name, '', '/devtools/panel.html'); 9 | 10 | panels.elements.createSidebarPane(name, (sidebar) => 11 | sidebar.setPage('/devtools/pane.html'), 12 | ); 13 | 14 | function onSelectionChanged() { 15 | chrome.devtools.inspectedWindow.eval( 16 | '__TESTING_PLAYGROUND__.onSelectionChanged($0)', 17 | { 18 | useContentScriptContext: true, 19 | }, 20 | ); 21 | } 22 | 23 | panels.elements.onSelectionChanged.addListener(onSelectionChanged); 24 | 25 | onSelectionChanged(); 26 | -------------------------------------------------------------------------------- /devtools/src/devtools/pane.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /devtools/src/devtools/pane.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime/runtime'; 2 | import React, { useState, useEffect } from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import Bridge from 'crx-bridge'; 5 | import Result from '../../../src/components/Result'; 6 | import inspectedWindow from './lib/inspectedWindow'; 7 | 8 | function Panel() { 9 | const [result, setResult] = useState({}); 10 | 11 | useEffect(() => { 12 | Bridge.onMessage('SELECT_NODE', ({ data }) => { 13 | setResult(data); 14 | }); 15 | }, [setResult]); 16 | 17 | const dispatch = (action) => { 18 | switch (action.type) { 19 | case 'SET_QUERY': { 20 | inspectedWindow.logQuery(action.query); 21 | break; 22 | } 23 | } 24 | }; 25 | 26 | return ( 27 |
28 | {result && } 29 |
30 | ); 31 | } 32 | 33 | ReactDOM.render(, document.getElementById('app')); 34 | -------------------------------------------------------------------------------- /devtools/src/devtools/panel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /devtools/src/devtools/panel.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime/runtime'; 2 | import React, { useState, useCallback, useEffect, useRef } from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import Bridge from 'crx-bridge'; 5 | import Query from '../../../src/components/Query'; 6 | import Result from '../../../src/components/Result'; 7 | import MenuBar from './components/MenuBar'; 8 | 9 | function Panel() { 10 | const [result, setResult] = useState({}); 11 | const editor = useRef(null); 12 | 13 | useEffect(() => { 14 | Bridge.onMessage('SELECT_NODE', ({ data }) => { 15 | setResult(data); 16 | editor.current.setValue(data.query); 17 | }); 18 | }, [setResult]); 19 | 20 | const dispatch = useCallback( 21 | (action) => { 22 | switch (action.type) { 23 | case 'SET_QUERY': { 24 | Bridge.sendMessage( 25 | 'PARSE_QUERY', 26 | { 27 | query: action.query, 28 | highlight: true, 29 | }, 30 | 'content-script', 31 | ).then(({ result }) => { 32 | setResult(result); 33 | }); 34 | 35 | if (action.origin !== 'EDITOR') { 36 | editor.current.setValue(action.query); 37 | } 38 | break; 39 | } 40 | 41 | case 'SET_QUERY_EDITOR': { 42 | editor.current = action.editor; 43 | } 44 | } 45 | }, 46 | [setResult], 47 | ); 48 | 49 | return ( 50 |
51 |
52 | 56 |
57 |
58 |
59 | 65 |
66 | 67 |
68 | 69 |
70 |
71 |
72 | ); 73 | } 74 | 75 | ReactDOM.render(, document.getElementById('app')); 76 | -------------------------------------------------------------------------------- /devtools/src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Testing Playground", 4 | "description": "Simple and complete DOM testing playground that encourage good testing practices.", 5 | "version": "1.16.0", 6 | "version_name": "1.16.3", 7 | "minimum_chrome_version": "58", 8 | "icons": { 9 | "16": "icons/16-production.png", 10 | "32": "icons/32-production.png", 11 | "48": "icons/48-production.png", 12 | "128": "icons/128-production.png" 13 | }, 14 | "browser_action": { 15 | "default_icon": { 16 | "16": "icons/16-production.png", 17 | "32": "icons/32-production.png", 18 | "48": "icons/48-production.png", 19 | "128": "icons/128-production.png" 20 | } 21 | }, 22 | "web_accessible_resources": ["window/testing-library.js"], 23 | "devtools_page": "devtools/main.html", 24 | "content_security_policy": "script-src 'self' 'unsafe-eval' 'sha256-6UcmjVDygSSU8p+3s7E7Kz8EG/ARhPADPRUm9P90HLM='; object-src 'self'", 25 | "background": { 26 | "scripts": ["background/background.js"], 27 | "persistent": false 28 | }, 29 | "permissions": ["clipboardWrite"], 30 | "content_scripts": [ 31 | { 32 | "matches": [""], 33 | "js": ["content-script/content-script.js"], 34 | "run_at": "document_start" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /devtools/src/window/testing-library.js: -------------------------------------------------------------------------------- 1 | import { 2 | screen, 3 | within, 4 | getSuggestedQuery, 5 | fireEvent, 6 | getRoles, 7 | } from '@testing-library/dom'; 8 | import userEvent from '@testing-library/user-event'; 9 | 10 | window.__TESTING_PLAYGROUND__ = window.__TESTING_PLAYGROUND__ || {}; 11 | 12 | function augmentQuery(query) { 13 | return (...args) => { 14 | const result = query(...args); 15 | 16 | // Promise.resolve(result).then((x) => { 17 | // if (x.nodeType) { 18 | // window.inspect(x); 19 | // } 20 | // }); 21 | 22 | return result; 23 | }; 24 | } 25 | 26 | export function setup(view) { 27 | // monkey patch `screen` to add testing library to console 28 | for (const prop of Object.keys(screen)) { 29 | view.screen[prop] = view.screen[prop] || augmentQuery(screen[prop]); 30 | view[prop] = view.screen[prop]; 31 | } 32 | 33 | view.getRoles = getRoles; 34 | view.fireEvent = fireEvent; 35 | view.getSuggestedQuery = getSuggestedQuery; 36 | view.within = within; 37 | 38 | view.container = view.document.body; 39 | view.userEvent = userEvent; 40 | view.user = userEvent; 41 | } 42 | 43 | setup(window); 44 | -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | ## Contributing with a new feature 2 | 3 | If you are thinking about adding a new feature to [Testing Playground](https://testing-playground.com/) and wondering where to start, you are in the right place. 4 | 5 | First, make sure that doesn't exist yet as [feature request](https://github.com/testing-library/testing-playground/issues?q=is%3Aissue+is%3Aopen+label%3Afeature). If it doesn't, go ahead and [create it](https://github.com/testing-library/testing-playground/issues/new?labels=feature&template=2.feature.md). 6 | 7 | We will advise you to first gather some feedback from the community and then proceed with coding. 8 | 9 | The project is divided on two applications: 10 | 11 | - [The website](https://testing-playground.com/) (in `src/`) 12 | - [The Extension](https://chrome.google.com/webstore/detail/testing-playground/hejbmebodbijjdhflfknehhcgaklhano?hl=en) (in `devtools/`) 13 | 14 | Have a look at the code to get the big picture, and follow the patterns we are currently using (naming conventions, folder structure...etc.). 15 | 16 | This is how our component hierarchy looks like right now: 17 | 18 | ![Component hierarchy](https://user-images.githubusercontent.com/1196524/85944695-17b16e80-b939-11ea-922b-ab00245a364a.png) 19 | 20 | ## You should also know... 21 | 22 | - We use [Taildwind](https://tailwindcss.com/) for CSS. 23 | - We use [ReachUI](https://reach.tech/) for interactive UI elements 24 | - We use [Octicons](https://primer.style/octicons/) icons 25 | - We use [undraw](https://undraw.co/illustrations) for illustrations 26 | - With color `#EDF2F7` and `.5` opacity. 27 | -------------------------------------------------------------------------------- /docs/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/docs/icon.png -------------------------------------------------------------------------------- /docs/testing-playground-com.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/docs/testing-playground-com.gif -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build:public && npm run build:lambda" 3 | functions = "./dist/server" 4 | publish = "./dist/public" 5 | 6 | [dev] 7 | command = "npm run dev:public" 8 | functions = "./src/lambda" 9 | autoLaunch = false 10 | publish = "." 11 | 12 | [template.environment] 13 | GITHUB_GIST_TOKEN = "set token that has gist access" 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Testing Playground", 3 | "version": "1.16.0", 4 | "description": "Simple and complete DOM testing playground that encourage good testing practices.", 5 | "author": "Stephan Meijer ", 6 | "homepage": "https://testing-playground.com", 7 | "license": "MIT", 8 | "private": true, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/testing-library/testing-playground" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/testing-library/testing-playground/issues" 15 | }, 16 | "scripts": { 17 | "dev": "netlify dev", 18 | "dev:public": "vite", 19 | "start": "run-s build:* start:*", 20 | "start:netlify": "netlify dev", 21 | "start:public": "cross-env NODE_ENV=production vite preview", 22 | "build": "run-s clean build:*", 23 | "build:public": "cross-env NODE_ENV=production vite build", 24 | "build:lambda": "cross-env NODE_ENV=production tsx ./scripts/build-lambda.mts", 25 | "build:chromium": "cross-env NODE_ENV=production tsx ./scripts/build-extension.mts --target chromium", 26 | "build:firefox": "cross-env NODE_ENV=production tsx ./scripts/build-extension.mts --target firefox-desktop", 27 | "lint": "run-s lint:*", 28 | "lint:eslint": "eslint . --quiet --fix", 29 | "lint:prettier": "prettier . --write", 30 | "clean": "run-p clean:*", 31 | "clean:cache": "rimraf ./.cache", 32 | "clean:dist": "rimraf ./dist", 33 | "ci:lint": "eslint", 34 | "ci:changelog": "node scripts/changelog.js", 35 | "test": "vitest", 36 | "cypress:open": "cypress open", 37 | "cypress:run": "cypress run" 38 | }, 39 | "dependencies": { 40 | "@primer/octicons-react": "^10.1.0", 41 | "@reach/dialog": "^0.10.5", 42 | "@reach/menu-button": "^0.12.1", 43 | "@testing-library/dom": "^9.3.0", 44 | "@testing-library/user-event": "^14.4.3", 45 | "codemirror": "5.54.0", 46 | "crx-bridge": "^2.1.0", 47 | "deep-diff": "^1.0.2", 48 | "dom-accessibility-api": "^0.4.7", 49 | "isomorphic-fetch": "^2.2.1", 50 | "js-beautify": "^1.13.0", 51 | "lodash.debounce": "4.0.8", 52 | "lodash.throttle": "^4.1.1", 53 | "lz-string": "^1.4.4", 54 | "memoize-one": "^5.1.1", 55 | "pretty-format": "26.0.1", 56 | "query-string": "^6.13.7", 57 | "react": "^16.14.0", 58 | "react-custom-scrollbars": "^4.2.1", 59 | "react-dom": "^16.14.0", 60 | "react-router-dom": "^5.2.0", 61 | "react-toastify": "^6.2.0", 62 | "react-toggle": "^4.1.1", 63 | "react-virtualized-auto-sizer": "^1.0.2", 64 | "react-window": "^1.8.6", 65 | "rollup-plugin-node-polyfills": "^0.2.1", 66 | "use-effect-reducer": "^0.6.1" 67 | }, 68 | "devDependencies": { 69 | "@babel/preset-react": "^7.18.6", 70 | "@testing-library/cypress": "^7.0.2", 71 | "@testing-library/jest-dom": "^5.11.6", 72 | "@testing-library/react": "^10.4.9", 73 | "@testing-library/react-hooks": "^3.7.0", 74 | "@types/fs-extra": "^9.0.4", 75 | "@types/node": "^20.1.4", 76 | "@typescript-eslint/parser": "^5.59.6", 77 | "@vitejs/plugin-react": "^4.0.0", 78 | "chrome-launch": "^1.1.4", 79 | "conventional-changelog": "^3.1.24", 80 | "conventional-changelog-config-spec": "^2.1.0", 81 | "cross-env": "^7.0.3", 82 | "cypress": "^6.0.1", 83 | "eslint": "^7.15.0", 84 | "eslint-config-prettier": "^6.15.0", 85 | "eslint-plugin-cypress": "^2.11.2", 86 | "eslint-plugin-prettier": "^3.2.0", 87 | "eslint-plugin-react": "^7.21.5", 88 | "eslint-plugin-react-hooks": "^4.2.0", 89 | "eslint-plugin-vitest-globals": "^1.3.1", 90 | "fs-extra": "^9.0.1", 91 | "get-port": "^5.1.1", 92 | "git-semver-tags": "^4.1.1", 93 | "husky": "^4.3.5", 94 | "jest-extended": "^0.11.5", 95 | "lint-staged": "^10.5.3", 96 | "netlify-cli": "^15.1.1", 97 | "npm-run-all": "^4.1.5", 98 | "postcss-import": "^15.1.0", 99 | "postcss-modules": "^6.0.0", 100 | "prettier": "^2.2.1", 101 | "prettier-plugin-tailwindcss": "^0.3.0", 102 | "react-test-renderer": "^16.14.0", 103 | "rimraf": "^5.0.1", 104 | "tailwindcss": "^1.9.6", 105 | "tsx": "^3.12.7", 106 | "vite": "^4.3.9", 107 | "vitest": "^0.31.1", 108 | "web-ext": "^5.5.0", 109 | "workbox-build": "^5.1.4", 110 | "zx": "^7.2.2" 111 | }, 112 | "keywords": [], 113 | "husky": { 114 | "hooks": { 115 | "pre-commit": "lint-staged" 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const tailwindcss = require('tailwindcss'); 2 | const atImport = require('postcss-import'); 3 | 4 | const IS_PRODUCTION = process.env.NODE_ENV === 'production'; 5 | 6 | const plugins = [atImport, tailwindcss]; 7 | 8 | if (IS_PRODUCTION) { 9 | const purgecss = require('@fullhuman/postcss-purgecss'); 10 | 11 | class TailwindExtractor { 12 | static extract(content) { 13 | return content.match(/[A-z0-9-:/]+/g) || []; 14 | } 15 | } 16 | 17 | plugins.push( 18 | purgecss({ 19 | content: [ 20 | 'src/*.html', 21 | 'src/**/*.js', 22 | 'devtools/**/*.js', 23 | 'devtools/**/*.html', 24 | ], 25 | whitelist: ['body', /CodeMirror/, /react-toggle/, /data-reach/], 26 | whitelistPatternsChildren: [ 27 | /CodeMirror/, 28 | /cm-s-dracula/, 29 | /react-toggle/, 30 | /data-reach/, 31 | ], 32 | defaultExtractor: TailwindExtractor.extract, 33 | }), 34 | ); 35 | } 36 | 37 | module.exports = { plugins }; 38 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /public/code_thinking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/code_thinking.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/favicon.ico -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icon.png -------------------------------------------------------------------------------- /public/icons/114-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/114-production.png -------------------------------------------------------------------------------- /public/icons/120-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/120-production.png -------------------------------------------------------------------------------- /public/icons/128-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/128-production.png -------------------------------------------------------------------------------- /public/icons/144-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/144-production.png -------------------------------------------------------------------------------- /public/icons/150-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/150-production.png -------------------------------------------------------------------------------- /public/icons/152-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/152-production.png -------------------------------------------------------------------------------- /public/icons/16-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/16-production.png -------------------------------------------------------------------------------- /public/icons/180-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/180-production.png -------------------------------------------------------------------------------- /public/icons/192-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/192-production.png -------------------------------------------------------------------------------- /public/icons/310-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/310-production.png -------------------------------------------------------------------------------- /public/icons/32-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/32-production.png -------------------------------------------------------------------------------- /public/icons/36-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/36-production.png -------------------------------------------------------------------------------- /public/icons/48-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/48-production.png -------------------------------------------------------------------------------- /public/icons/57-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/57-production.png -------------------------------------------------------------------------------- /public/icons/60-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/60-production.png -------------------------------------------------------------------------------- /public/icons/70-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/70-production.png -------------------------------------------------------------------------------- /public/icons/72-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/72-production.png -------------------------------------------------------------------------------- /public/icons/76-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/76-production.png -------------------------------------------------------------------------------- /public/icons/96-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/icons/96-production.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Testing Playground", 3 | "description": "Simple and complete DOM testing playground that encourage good testing practices.", 4 | "lang": "en", 5 | "theme_color": "#ffffff", 6 | "background_color": "#ffffff", 7 | "display": "standalone", 8 | "orientation": "any", 9 | "start_url": "/", 10 | "icons": [ 11 | { 12 | "src": "icons/36-production.png", 13 | "sizes": "36x36", 14 | "type": "image/png", 15 | "density": "0.75" 16 | }, 17 | { 18 | "src": "icons/48-production.png", 19 | "sizes": "48x48", 20 | "type": "image/png", 21 | "density": "1.0" 22 | }, 23 | { 24 | "src": "icons/72-production.png", 25 | "sizes": "72x72", 26 | "type": "image/png", 27 | "density": "1.5" 28 | }, 29 | { 30 | "src": "icons/96-production.png", 31 | "sizes": "96x96", 32 | "type": "image/png", 33 | "density": "2.0" 34 | }, 35 | { 36 | "src": "icons/144-production.png", 37 | "sizes": "144x144", 38 | "type": "image/png", 39 | "density": "3.0" 40 | }, 41 | { 42 | "src": "icons/192-production.png", 43 | "sizes": "192x192", 44 | "type": "image/png", 45 | "density": "4.0" 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /public/site.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/testing-library/testing-playground/2eae3e5c3d193e7792db3b9b00666cdc26bec929/public/site.jpg -------------------------------------------------------------------------------- /scripts/build-extension.mts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S npx tsx 2 | import { argv, path, fs } from 'zx'; 3 | import { build } from 'vite'; 4 | import {config} from './vite.config.mjs'; 5 | import { resolve as r, __DEV__ } from './utils.mjs' 6 | import webExt from 'web-ext'; 7 | 8 | const TARGETS = ['chromium', 'firefox-desktop']; 9 | const { target } = argv; 10 | 11 | if (!TARGETS.includes(target)) { 12 | console.log(` 13 | Invalid target: ${target}, must be one of ${TARGETS.join(', ')} 14 | 15 | Usage: 16 | ./scripts/build-extension.mts --target chromium 17 | ./scripts/build-extension.mts --target firefox-desktop 18 | `); 19 | process.exit(1); 20 | } 21 | 22 | const root = r('devtools/src'); 23 | const dest = r(`dist/${target}-extension`); 24 | 25 | const entries = [ 26 | 'devtools/main.html', 27 | 'devtools/pane.html', 28 | 'devtools/panel.html', 29 | 'content-script/content-script.js', 30 | 'background/background.js', 31 | 'window/testing-library.js', 32 | ]; 33 | 34 | await fs.rm(dest, { recursive: true, force: true }); 35 | for (const entry of entries) 36 | { 37 | const isHTML = /\.html$/.test(entry); 38 | const dirname = path.dirname(entry); 39 | const basename = path.basename(entry); 40 | 41 | const libEntry = r(root, entry.replace(/\.html$/, '.js')); 42 | const entryFileNames = `${dirname}/${basename.replace(/\.html$/, '.js')}`; 43 | 44 | await new Promise((resolve) => 45 | build({ 46 | ...config, 47 | plugins: [ 48 | ...config.plugins, 49 | { name: 'done', buildEnd: resolve }, 50 | ], 51 | root, 52 | build: { 53 | watch: __DEV__ ? {} : null, 54 | sourcemap: __DEV__ ? 'inline' : false, 55 | outDir: r(`dist/${target}-extension`), 56 | emptyOutDir: false, 57 | minify: false, 58 | cssCodeSplit: false, 59 | lib: isHTML ? undefined : { 60 | entry: libEntry, 61 | fileName: (_, name) => `${dirname}/${name}.js`, 62 | formats: ['iife'], 63 | name: entry, 64 | }, 65 | rollupOptions: isHTML ? { 66 | input: r(root, entry), 67 | output: { 68 | entryFileNames, 69 | manualChunks: () => null, 70 | preserveModules: false, 71 | } 72 | } : undefined, 73 | } 74 | }) 75 | ); 76 | } 77 | 78 | await fs.copy(r(root, 'manifest.json'), r(dest, 'manifest.json')); 79 | await fs.mkdir(r(dest, 'icons'), { recursive: true }); 80 | const manifest = await fs.readJson(r(dest, 'manifest.json')); 81 | 82 | await Promise.all( 83 | Object.values(manifest.icons).map((icon: string) => 84 | fs.copyFile(r('public', icon), r(dest, icon)), 85 | ), 86 | ); 87 | 88 | if (__DEV__) { 89 | webExt.cmd.run({ sourceDir: dest, target, browserConsole: true, startUrl: 'https://meijer.ws' }); 90 | } else { 91 | webExt.cmd.build({ 92 | sourceDir: dest, 93 | target, 94 | artifactsDir: r('dist'), 95 | overwriteDest: true, 96 | filename: `${manifest.name}-${manifest.version}-${target}.zip`.replace(/\s/g, '-').toLowerCase(), 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /scripts/build-lambda.mts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S npx tsx 2 | import { $, fs } from 'zx'; 3 | import { resolve as r } from "./utils.mjs"; 4 | 5 | const root = r('src/lambda'); 6 | const dest = r('dist/server'); 7 | 8 | await fs.rm(dest, { recursive: true, force: true }); 9 | 10 | if (!fs.existsSync(r('dist/public/index.html'))) { 11 | console.log(`public not built yet, building...`); 12 | await $`npm run build:public`; 13 | } 14 | 15 | await fs.copy(root, dest); 16 | await fs.copy(r('dist/public/index.html'), r(dest,'server/index.html')); 17 | 18 | -------------------------------------------------------------------------------- /scripts/changelog.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const conventionalChangelog = require('conventional-changelog'); 3 | const spec = require('conventional-changelog-config-spec'); 4 | const gitSemverTags = require('git-semver-tags'); 5 | const { promisify } = require('util'); 6 | const semverTags = promisify(gitSemverTags); 7 | 8 | function compact(obj) { 9 | const res = {}; 10 | 11 | Object.keys(obj).forEach((key) => { 12 | if (obj[key] !== undefined) { 13 | res[key] = obj[key]; 14 | } 15 | }); 16 | 17 | return res; 18 | } 19 | 20 | const preset = { 21 | name: require.resolve('conventional-changelog-conventionalcommits'), 22 | types: spec.properties.types.default.map((x) => { 23 | if (x.type === 'fix') { 24 | return { ...x, section: 'Fixes' }; 25 | } 26 | 27 | if (x.type === 'feat') { 28 | return x; 29 | } 30 | 31 | return { ...x, section: 'Other' }; 32 | }), 33 | }; 34 | 35 | async function generate({ version, from, to, showHeader } = {}) { 36 | let content = ''; 37 | 38 | return new Promise((resolve, reject) => { 39 | const changelogStream = conventionalChangelog( 40 | { 41 | preset, 42 | tagPrefix: '', 43 | }, 44 | { version }, 45 | compact({ merges: null, path: './', from, to }), 46 | ).on('error', function (err) { 47 | return reject(err); 48 | }); 49 | 50 | changelogStream.on('data', function (buffer) { 51 | content += buffer.toString(); 52 | }); 53 | 54 | changelogStream.on('end', function () { 55 | let lines = content.split('\n'); 56 | if (showHeader) { 57 | lines[0] = `## Release Notes for ${lines[0].substr(3).trim()}`; 58 | } else { 59 | lines = lines.slice(3); // strip header and 2 blank lines 60 | } 61 | 62 | lines = lines.map((x) => (x.startsWith('#') ? x.substr(1) : x)); 63 | 64 | content = lines.join('\n'); 65 | return resolve(content); 66 | }); 67 | }); 68 | } 69 | 70 | async function main() { 71 | const [version, from] = await semverTags(); 72 | 73 | generate({ version, from }).then((x) => { 74 | // eslint-disable-next-line no-console 75 | console.log(x); 76 | }); 77 | } 78 | 79 | main(); 80 | -------------------------------------------------------------------------------- /scripts/crxmake.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Purpose: Pack a Chromium extension directory into crx format 4 | # src: https://stackoverflow.com/questions/18693962/pack-chrome-extension-on-server-with-only-command-line-interface 5 | 6 | if test $# -ne 2; then 7 | echo "Usage: crxmake.sh " 8 | exit 1 9 | fi 10 | 11 | dir=$1 12 | key=$2 13 | name=$(basename "$dir") 14 | crx="$name.crx" 15 | pub="$name.pub" 16 | sig="$name.sig" 17 | zip="$name.zip" 18 | tosign="$name.presig" 19 | binary_crx_id="$name.crxid" 20 | trap 'rm -f "$pub" "$sig" "$zip" "$tosign" "$binary_crx_id"' EXIT 21 | 22 | 23 | # zip up the crx dir 24 | cwd=$(pwd -P) 25 | (cd "$dir" && zip -qr -9 -X "$cwd/$zip" .) 26 | 27 | 28 | #extract crx id 29 | openssl rsa -in "$key" -pubout -outform der | openssl dgst -sha256 -binary -out "$binary_crx_id" 30 | truncate -s 16 "$binary_crx_id" 31 | 32 | #generate file to sign 33 | ( 34 | # echo "$crmagic_hex $version_hex $header_length $pub_len_hex $sig_len_hex" 35 | printf "CRX3 SignedData" 36 | echo "00 12 00 00 00 0A 10" | xxd -r -p 37 | cat "$binary_crx_id" "$zip" 38 | ) > "$tosign" 39 | 40 | # signature 41 | openssl dgst -sha256 -binary -sign "$key" < "$tosign" > "$sig" 42 | 43 | # public key 44 | openssl rsa -pubout -outform DER < "$key" > "$pub" 2>/dev/null 45 | 46 | 47 | crmagic_hex="43 72 32 34" # Cr24 48 | version_hex="03 00 00 00" # 3 49 | header_length="45 02 00 00" 50 | header_chunk_1="12 AC 04 0A A6 02" 51 | header_chunk_2="12 80 02" 52 | header_chunk_3="82 F1 04 12 0A 10" 53 | ( 54 | echo "$crmagic_hex $version_hex $header_length $header_chunk_1" | xxd -r -p 55 | cat "$pub" 56 | echo "$header_chunk_2" | xxd -r -p 57 | cat "$sig" 58 | echo "$header_chunk_3" | xxd -r -p 59 | cat "$binary_crx_id" "$zip" 60 | ) > "$crx" 61 | echo "Wrote $crx" -------------------------------------------------------------------------------- /scripts/utils.mts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "url"; 2 | import * as path from "path"; 3 | 4 | const __dirname = fileURLToPath(new URL('.', import.meta.url)); 5 | 6 | export const repoRoot = path.resolve(__dirname, '..'); 7 | 8 | export const __DEV__ = process.env.NODE_ENV === 'development'; 9 | 10 | export const resolve = (...p) => { 11 | const segments = p.flatMap(p => p.replace(repoRoot, '').split('/')) 12 | return path.resolve(repoRoot, ...segments.filter(Boolean)); 13 | } 14 | -------------------------------------------------------------------------------- /scripts/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { InlineConfig } from 'vite'; 2 | import { resolve as r } from './utils.mjs'; 3 | import * as fs from 'fs-extra'; 4 | import react from '@vitejs/plugin-react'; 5 | 6 | export const config: InlineConfig = { 7 | envDir: r(), 8 | resolve: { 9 | alias: { 10 | '~': r(), 11 | events: 'rollup-plugin-node-polyfills/polyfills/events', 12 | }, 13 | }, 14 | esbuild: { loader: 'jsx', include: /src\/.*\.jsx?$/, exclude: [] }, 15 | optimizeDeps: { 16 | esbuildOptions: { 17 | plugins: [ 18 | { 19 | name: 'load-js-files-as-jsx', 20 | setup(build) { 21 | build.onLoad({ filter: /src\/.*\.js$/ }, async (args) => ({ 22 | loader: 'jsx', 23 | contents: await fs.readFile(args.path, 'utf8'), 24 | })); 25 | }, 26 | }, 27 | ], 28 | }, 29 | }, 30 | plugins: [react()], 31 | }; 32 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; 3 | import { PreviewEventsProvider } from './context/PreviewEvents'; 4 | import Loader from './components/Loader'; 5 | 6 | const Playground = React.lazy(() => import('./pages/Playground')); 7 | const Embedded = React.lazy(() => import('./pages/Embedded')); 8 | 9 | function App() { 10 | return ( 11 | 12 | 13 | }> 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | export default App; 28 | -------------------------------------------------------------------------------- /src/components/CopyButton.js: -------------------------------------------------------------------------------- 1 | /* global chrome */ 2 | import React, { useState, useEffect } from 'react'; 3 | import IconButton from './IconButton'; 4 | import { CopyIcon, CheckIcon } from '@primer/octicons-react'; 5 | 6 | const IS_DEVTOOL = !!(window.chrome && chrome.runtime && chrome.runtime.id); 7 | 8 | /** 9 | * 10 | * @param {string} suggestion 11 | */ 12 | async function attemptCopyToClipboard(suggestion) { 13 | try { 14 | if (!IS_DEVTOOL && 'clipboard' in navigator) { 15 | await navigator.clipboard.writeText(suggestion); 16 | return true; 17 | } 18 | 19 | const input = Object.assign(document.createElement('input'), { 20 | type: 'text', 21 | value: suggestion, 22 | }); 23 | 24 | document.body.append(input); 25 | input.select(); 26 | document.execCommand('copy'); 27 | input.remove(); 28 | 29 | return true; 30 | } catch (error) { 31 | console.error(error); 32 | return false; 33 | } 34 | } 35 | 36 | /** 37 | * 38 | * @param {{ 39 | * text: string | function; 40 | * title: string; 41 | * className: string; 42 | * variant: string; 43 | * }} props 44 | */ 45 | function CopyButton({ text, title, className, variant }) { 46 | const [copied, setCopied] = useState(false); 47 | 48 | useEffect(() => { 49 | if (copied) { 50 | const timeout = setTimeout(() => { 51 | setCopied(false); 52 | }, 1500); 53 | 54 | return () => clearTimeout(timeout); 55 | } 56 | }, [copied]); 57 | 58 | async function handleClick() { 59 | let textToCopy = text; 60 | if (typeof text === 'function') { 61 | textToCopy = text(); 62 | } 63 | const wasSuccessfullyCopied = await attemptCopyToClipboard(textToCopy); 64 | 65 | if (wasSuccessfullyCopied) { 66 | setCopied(true); 67 | } 68 | } 69 | 70 | return ( 71 | 77 | {copied ? : } 78 | 79 | ); 80 | } 81 | 82 | export default CopyButton; 83 | -------------------------------------------------------------------------------- /src/components/CopyButton.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CopyButton from './CopyButton'; 3 | import { render, fireEvent, act, waitFor } from '@testing-library/react'; 4 | 5 | const defaultProps = { 6 | text: 'string', 7 | title: 'title', 8 | }; 9 | 10 | beforeEach(() => { 11 | delete window.navigator.clipboard; 12 | delete document.execCommand; 13 | }); 14 | 15 | it('renders without crashing given default props', () => { 16 | render(); 17 | }); 18 | 19 | it('attempts to copy to clipboard through navigator.clipboard', async () => { 20 | const clipboardSpy = vi.fn(); 21 | 22 | window.navigator.clipboard = { 23 | writeText: clipboardSpy, 24 | }; 25 | 26 | const { getByRole } = render(); 27 | 28 | await act(async () => { 29 | fireEvent.click(getByRole('button')); 30 | }); 31 | 32 | expect(clipboardSpy).toHaveBeenCalledWith(defaultProps.text); 33 | expect(clipboardSpy).toHaveBeenCalledTimes(1); 34 | }); 35 | 36 | it('attempts to copy with legacy methods if navigator.clipboard is unavailable', async () => { 37 | const execCommandSpy = vi.fn(); 38 | 39 | document.execCommand = execCommandSpy; 40 | 41 | const { getByRole } = render(); 42 | 43 | await act(async () => { 44 | fireEvent.click(getByRole('button')); 45 | }); 46 | 47 | expect(execCommandSpy).toHaveBeenCalledWith('copy'); 48 | expect(execCommandSpy).toHaveBeenCalledTimes(1); 49 | }); 50 | 51 | it('temporarily shows a different icon after copying', async () => { 52 | vi.useFakeTimers(); 53 | const execCommandSpy = vi.fn(); 54 | 55 | document.execCommand = execCommandSpy; 56 | 57 | const { getByRole } = render(); 58 | 59 | const button = getByRole('button'); 60 | 61 | const initialIcon = button.innerHTML; 62 | 63 | // act due to useEffect state change 64 | await act(async () => { 65 | fireEvent.click(button); 66 | }); 67 | 68 | await waitFor(() => { 69 | expect(button.innerHTML).not.toBe(initialIcon); 70 | }); 71 | 72 | // same here 73 | await act(async () => { 74 | vi.runAllTimers(); 75 | }); 76 | 77 | await waitFor(() => { 78 | expect(button.innerHTML).toBe(initialIcon); 79 | }); 80 | }); 81 | 82 | it('should accept funcition to get text to copy', async () => { 83 | const execCommandSpy = vi.fn(); 84 | const getTextToCopy = () => 'copy'; 85 | 86 | document.execCommand = execCommandSpy; 87 | 88 | const { getByRole } = render( 89 | , 90 | ); 91 | 92 | await act(async () => { 93 | fireEvent.click(getByRole('button')); 94 | }); 95 | 96 | expect(execCommandSpy).toHaveBeenCalledWith('copy'); 97 | expect(execCommandSpy).toHaveBeenCalledTimes(1); 98 | }); 99 | -------------------------------------------------------------------------------- /src/components/DomEvents.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { ChevronUpIcon, ChevronDownIcon } from '@primer/octicons-react'; 3 | 4 | import AutoSizer from 'react-virtualized-auto-sizer'; 5 | import { TrashcanIcon } from '@primer/octicons-react'; 6 | 7 | import { VirtualScrollable } from './Scrollable'; 8 | import IconButton from './IconButton'; 9 | import CopyButton from './CopyButton'; 10 | import EmptyStreetImg from '../images/EmptyStreetImg'; 11 | import StickyList from './StickyList'; 12 | import { 13 | usePreviewEvents, 14 | usePreviewEventsActions, 15 | } from '../context/PreviewEvents'; 16 | 17 | function EventRecord({ index, style, data }) { 18 | const { id, type, name, element, selector } = data[index]; 19 | 20 | return ( 21 |
27 |
{id}
28 | 29 |
{type}
30 |
{name}
31 | 32 |
{element}
33 |
{selector}
34 |
35 | ); 36 | } 37 | 38 | function DomEvents() { 39 | const listRef = useRef(); 40 | const { sortDirection, buffer, appendMode, eventCount } = usePreviewEvents(); 41 | const { changeSortDirection, reset } = usePreviewEventsActions(); 42 | 43 | const getSortIcon = () => ( 44 | 45 | {sortDirection.current === 'desc' ? ( 46 | 47 | ) : ( 48 | 49 | )} 50 | 51 | ); 52 | 53 | const getTextToCopy = () => 54 | buffer.current 55 | .map((log) => `${log.target.toString()} - ${log.event.EventType}`) 56 | .join('\n'); 57 | 58 | return ( 59 |
60 |
61 |
62 |
66 | # {getSortIcon()} 67 |
68 | 69 |
type
70 |
name
71 | 72 |
element
73 |
74 | selector 75 |
76 | 81 | 82 | 83 | 84 |
85 |
86 |
87 | 88 |
89 | {buffer.current.length === 0 ? ( 90 |
91 | 92 |
93 | ) : ( 94 | 95 | {({ width, height }) => ( 96 | 106 | {EventRecord} 107 | 108 | )} 109 | 110 | )} 111 |
112 |
113 |
114 | ); 115 | } 116 | 117 | export default DomEvents; 118 | -------------------------------------------------------------------------------- /src/components/Embed.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import Input from './Input'; 3 | import CopyButton from './CopyButton'; 4 | import Embedded from '../pages/Embedded'; 5 | import { SyncIcon, XIcon } from '@primer/octicons-react'; 6 | 7 | import { defaultPanes } from '../constants'; 8 | import TabButton from './TabButton'; 9 | 10 | const possiblePanes = ['markup', 'preview', 'query', 'result']; 11 | 12 | const styles = { 13 | section: { width: 850 }, 14 | frame: { width: 850, height: 375 }, 15 | }; 16 | 17 | // TODO: make the preview frame height match the end result, and let 18 | // the user modify the frame height 19 | function Embed({ dispatch, dirty, gistId, gistVersion }) { 20 | useEffect(() => { 21 | if (!dirty) { 22 | return; 23 | } 24 | 25 | dispatch({ type: 'SAVE' }); 26 | }, [dirty, gistId, dispatch]); 27 | 28 | const [panes, setPanes] = useState(defaultPanes); 29 | 30 | const embedUrl = 31 | [location.origin, 'embed', gistId, gistVersion].filter(Boolean).join('/') + 32 | `?panes=${panes.join(',')}`; 33 | 34 | const embedCode = ``; 35 | const canAddPane = panes.length < 3; 36 | 37 | const loader = ( 38 |
39 | 40 | one sec... 41 |
42 | ); 43 | 44 | return ( 45 |
46 |
47 |
48 |
49 |

Configure

50 | 52 | setPanes([ 53 | ...panes, 54 | possiblePanes.find((x) => !panes.includes(x)), 55 | ]) 56 | } 57 | disabled={!canAddPane} 58 | > 59 | add pane 60 | 61 |
62 | 63 | {/* overflow-hidden is required hide the hidden preview panel */} 64 |
65 |
66 | {panes.map((selected, idx) => ( 67 |
68 |
69 | {possiblePanes.map((name) => ( 70 | 73 | setPanes((current) => { 74 | const next = [...current]; 75 | next[idx] = name; 76 | return next; 77 | }) 78 | } 79 | active={selected === name} 80 | > 81 | {name} 82 | 83 | ))} 84 |
85 | setPanes(panes.filter((_, i) => i !== idx))} 88 | > 89 | 90 | 91 | 92 | 93 |
94 | ))} 95 |
96 | 97 |
98 | {dirty ? null : ( 99 | 105 | )} 106 |
107 |
108 |
109 | 110 |
111 |

Copy & Paste

112 | 113 | 129 | 130 | 143 |
144 |
145 |
146 | ); 147 | } 148 | 149 | export default Embed; 150 | -------------------------------------------------------------------------------- /src/components/EmptyPane.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import icon from '~/public/code_thinking.png'; 3 | 4 | function EmptyPane() { 5 | return ( 6 |
7 |
8 | 12 |
13 |
14 | ); 15 | } 16 | 17 | export default EmptyPane; 18 | -------------------------------------------------------------------------------- /src/components/ErrorBox.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Scrollable from './Scrollable'; 3 | 4 | function ErrorBox({ caption, body }) { 5 | return ( 6 |
7 | 8 |
9 |
Error: {caption}
10 | 11 |
12 | {body} 13 |
14 |
15 |
16 |
17 | ); 18 | } 19 | 20 | export default ErrorBox; 21 | -------------------------------------------------------------------------------- /src/components/Expandable.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import IconButton from './IconButton'; 3 | import Scrollable from './Scrollable'; 4 | import { ChevronUpIcon, ChevronDownIcon } from '@primer/octicons-react'; 5 | 6 | function Expandable({ excerpt, children, className, variant, labelText }) { 7 | const [expanded, setExpanded] = useState(false); 8 | 9 | return ( 10 |
20 | {expanded && ( 21 |
22 | 23 |
24 | {children}
 
25 |
26 |
27 | 28 | setExpanded(!expanded)} 32 | > 33 | 34 | 35 |
36 | )} 37 | 38 | {expanded || !children ? ( 39 |
 
40 | ) : ( 41 |
42 | {excerpt || children} 43 |
44 | )} 45 | 46 | setExpanded(!expanded)} 50 | title="expand" 51 | > 52 | 53 | 54 |
55 | ); 56 | } 57 | 58 | export default Expandable; 59 | -------------------------------------------------------------------------------- /src/components/IconButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const variants = { 4 | dark: 'text-gray-500 hover:text-gray-400', 5 | light: 'text-gray-500 hover:text-gray-700', 6 | white: 'text-white hover:text-white', 7 | }; 8 | 9 | function IconButton({ children, title, variant, onClick, className }) { 10 | const cssVariant = variants[variant] || variants['light']; 11 | return ( 12 | 25 | ); 26 | } 27 | 28 | export default IconButton; 29 | -------------------------------------------------------------------------------- /src/components/Input.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Input(props) { 4 | return ( 5 | 9 | ); 10 | } 11 | 12 | export default Input; 13 | -------------------------------------------------------------------------------- /src/components/Layout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Header from './Header'; 3 | import { ToastContainer } from 'react-toastify'; 4 | 5 | function Layout({ 6 | children, 7 | dirty, 8 | gistId, 9 | gistVersion, 10 | dispatch, 11 | status, 12 | settings, 13 | }) { 14 | return ( 15 |
16 |
17 |
27 |
28 | 29 | {/*not sure why, but safari needs a height here*/} 30 |
31 | {children} 32 |
33 | 34 | 41 |
42 | ); 43 | } 44 | 45 | export default Layout; 46 | -------------------------------------------------------------------------------- /src/components/Loader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import frog from '~/public/icons/128-production.png'; 3 | 4 | function Loader({ loading }) { 5 | return ( 6 |
12 | 13 |
14 | ); 15 | } 16 | 17 | export default Loader; 18 | -------------------------------------------------------------------------------- /src/components/MarkupEditor.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | import Editor from './Editor'; 3 | 4 | function MarkupEditor({ markup, dispatch }) { 5 | const [initialValue] = useState(markup); 6 | 7 | const onLoad = useCallback( 8 | (editor) => dispatch({ type: 'SET_MARKUP_EDITOR', editor }), 9 | [dispatch], 10 | ); 11 | 12 | const onChange = useCallback( 13 | (markup, { origin }) => 14 | dispatch({ 15 | type: 'SET_MARKUP', 16 | markup, 17 | origin: 'EDITOR', 18 | immediate: origin === 'user', 19 | }), 20 | [dispatch], 21 | ); 22 | 23 | return ( 24 |
25 |
26 | 32 |
33 |
34 | ); 35 | } 36 | 37 | export default React.memo(MarkupEditor); 38 | -------------------------------------------------------------------------------- /src/components/Menu.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as ReachMenu from '@reach/menu-button'; 3 | 4 | export const Menu = ReachMenu.Menu; 5 | 6 | export const MenuButton = (props) => ( 7 | 8 | ); 9 | 10 | export const MenuPopover = (props) => ( 11 | 15 | ); 16 | 17 | export const MenuList = (props) => ( 18 | 22 | ); 23 | 24 | export const MenuLink = (props) => ( 25 | 30 | ); 31 | -------------------------------------------------------------------------------- /src/components/Modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dialog } from '@reach/dialog'; 3 | import { XIcon } from '@primer/octicons-react'; 4 | import IconButton from './IconButton'; 5 | 6 | const callAll = (...fns) => (...args) => fns.forEach((fn) => fn && fn(...args)); 7 | 8 | const ModalContext = React.createContext(); 9 | 10 | function Modal(props) { 11 | const [isOpen, setIsOpen] = React.useState(props.open ?? false); 12 | 13 | return ; 14 | } 15 | 16 | function ModalDismissButton({ children: child }) { 17 | const [, setIsOpen] = React.useContext(ModalContext); 18 | return React.cloneElement(child, { 19 | onClick: callAll(() => setIsOpen(false), child.props.onClick), 20 | }); 21 | } 22 | 23 | function ModalOpenButton({ children: child }) { 24 | const [, setIsOpen] = React.useContext(ModalContext); 25 | return React.cloneElement(child, { 26 | onClick: callAll(() => setIsOpen(true), child.props.onClick), 27 | }); 28 | } 29 | 30 | function ModalContentsBase(props) { 31 | const [isOpen, setIsOpen] = React.useContext(ModalContext); 32 | return ( 33 | setIsOpen(false)} {...props} /> 34 | ); 35 | } 36 | 37 | function ModalContents({ title, children, ...props }) { 38 | return ( 39 | 44 | {title && ( 45 |
46 |

{title}

47 | 48 | 49 | 50 | 51 | 52 | 53 |
54 | )} 55 | 56 | {children} 57 |
58 | ); 59 | } 60 | 61 | export { Modal, ModalDismissButton, ModalOpenButton, ModalContents }; 62 | -------------------------------------------------------------------------------- /src/components/PlaygroundPanels.js: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { useState } from 'react'; 3 | 4 | import Query from './Query'; 5 | import Result from './Result'; 6 | import TabButton from './TabButton'; 7 | 8 | const panels = ['Query', 'Events']; 9 | 10 | const DomEvents = React.lazy(() => import('./DomEvents')); 11 | 12 | function Paper({ children }) { 13 | return ( 14 |
15 | {children} 16 |
17 | ); 18 | } 19 | 20 | function PlaygroundPanels({ state, dispatch }) { 21 | const { query, result } = state; 22 | const [panel, setPanel] = useState(panels[0]); 23 | 24 | return ( 25 | <> 26 |
27 |
28 |
29 | {panels.map((panelName) => ( 30 | setPanel(panelName)} 33 | active={panelName === panel} 34 | > 35 | {panelName} 36 | 37 | ))} 38 |
39 |
40 |
41 | 42 | {panel === panels[0] && ( 43 | 44 |
45 | 46 |
47 | 48 |
49 | 50 |
51 |
52 | )} 53 | {panel === panels[1] && } 54 |
55 | 56 | ); 57 | } 58 | 59 | export default PlaygroundPanels; 60 | -------------------------------------------------------------------------------- /src/components/Preview.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef, useCallback } from 'react'; 2 | import PreviewHint from './PreviewHint'; 3 | import EmptyPane from './EmptyPane'; 4 | import { getRoles } from '@testing-library/dom'; 5 | 6 | function getSandbox(ref) { 7 | try { 8 | const document = 9 | ref.current?.contentDocument || 10 | ref.current?.contentWindow?.document || 11 | null; 12 | 13 | if (document) { 14 | document.__SANDBOX_ROOT__ = 15 | document.__SANDBOX_ROOT__ || document.getElementById('sandbox'); 16 | 17 | return { 18 | document: document, 19 | root: document.__SANDBOX_ROOT__, 20 | }; 21 | } 22 | } catch (e) { 23 | console.log( 24 | 'iframe navigated away from this origin, we no longer have access to the document', 25 | ); 26 | } 27 | 28 | return { document: null, root: null }; 29 | } 30 | 31 | function Preview({ markup, variant, forwardedRef, dispatch }) { 32 | const [roles, setRoles] = useState([]); 33 | const [suggestion, setSuggestion] = useState({}); 34 | 35 | const frameRef = useRef(); 36 | 37 | useEffect(() => { 38 | if (!frameRef.current) { 39 | return; 40 | } 41 | 42 | frameRef.current.contentWindow.addEventListener('click', () => { 43 | const click = new MouseEvent('mousedown', { 44 | bubbles: true, 45 | cancelable: true, 46 | }); 47 | document.body.dispatchEvent(click); 48 | }); 49 | }, []); 50 | 51 | const handleLoadIframe = useCallback(() => { 52 | dispatch({ 53 | type: 'SET_SANDBOX_FRAME', 54 | sandbox: frameRef.current.contentWindow, 55 | }); 56 | }, [dispatch]); 57 | 58 | useEffect(() => { 59 | const listener = ({ data: { source, type, suggestion } = {} }) => { 60 | if (source !== 'testing-playground-sandbox') { 61 | return; 62 | } 63 | 64 | switch (type) { 65 | case 'SANDBOX_LOADED': { 66 | const { root } = getSandbox(frameRef); 67 | 68 | //setInnerHTML(root, markup); 69 | setRoles(Object.keys(getRoles(root) || {}).sort()); 70 | 71 | if (typeof forwardedRef === 'function') { 72 | forwardedRef(root); 73 | } 74 | 75 | break; 76 | } 77 | 78 | case 'SELECT_NODE': { 79 | dispatch({ 80 | type: 'SET_QUERY', 81 | query: suggestion.snippet, 82 | origin: 'SANDBOX', 83 | }); 84 | setSuggestion({ selected: suggestion }); 85 | break; 86 | } 87 | 88 | case 'HOVER_NODE': { 89 | setSuggestion((state) => ({ ...state, hovered: suggestion })); 90 | break; 91 | } 92 | } 93 | }; 94 | 95 | window.addEventListener('message', listener, false); 96 | return () => window.removeEventListener('message', listener); 97 | }, [dispatch, forwardedRef, markup]); 98 | 99 | return ( 100 |
101 | `, 77 | width: maxwidth, 78 | height: maxheight, 79 | 80 | thumbnail_url: `${host}/icon.png`, 81 | thumbnail_width: 512, 82 | thumbnail_height: 512, 83 | 84 | referrer, 85 | cache_age: 3600, 86 | }, 87 | '', 88 | ' ', 89 | ), 90 | }); 91 | } 92 | 93 | module.exports = { handler }; 94 | -------------------------------------------------------------------------------- /src/lambda/server/server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const queryString = require('query-string'); 4 | 5 | const filename = path.join(__dirname, './index.html'); 6 | const indexHtml = fs.existsSync(filename) 7 | ? fs.readFileSync(filename, 'utf8') 8 | : fs.readFileSync( 9 | path.join(__dirname, '../../../dist/public/index.html'), 10 | 'utf8', 11 | ); 12 | 13 | function getHostname(event, context) { 14 | if (event.headers.host) { 15 | return `http://${event.headers.host}`; 16 | } 17 | 18 | const { netlify } = context.clientContext.custom || {}; 19 | 20 | return JSON.parse(Buffer.from(netlify, 'base64').toString('utf-8')).site_url; 21 | } 22 | 23 | function handler(event, context, callback) { 24 | const { panes, markup, query } = event.queryStringParameters; 25 | const host = getHostname(event, context); 26 | 27 | const embedPath = event.path.replace('/gist/', '/embed/'); 28 | const frameSearch = queryString.stringify({ panes, markup, query }); 29 | const frameSrc = host + embedPath + (frameSearch ? `?${frameSearch}` : ''); 30 | 31 | const oembedSearch = queryString.stringify({ url: frameSrc }); 32 | 33 | const oembedLink = [ 34 | ``, 35 | ``, 36 | ].join(''); 37 | 38 | let body = indexHtml.replace( 39 | /<(\w+)\s[^>]*type="application\/json\+oembed".*?>/g, 40 | oembedLink, 41 | ); 42 | 43 | return callback(null, { 44 | statusCode: 200, 45 | body, 46 | headers: { 47 | 'Cache-Control': 'public, s-maxage=15, stale-while-revalidate=300', 48 | 'Content-Type': 'text/html; charset=UTF-8', 49 | }, 50 | }); 51 | } 52 | 53 | module.exports = { handler }; 54 | -------------------------------------------------------------------------------- /src/lib/beautify.js: -------------------------------------------------------------------------------- 1 | import beautify from 'js-beautify'; 2 | 3 | const beautifyOptions = { 4 | indent_size: 2, 5 | wrap_line_length: 80, 6 | wrap_attributes: 'force-expand-multiline', 7 | }; 8 | 9 | function html(html) { 10 | return beautify.html(html, beautifyOptions); 11 | } 12 | 13 | function js(js) { 14 | return beautify.js(js, beautifyOptions); 15 | } 16 | 17 | function format(mode, content) { 18 | if (mode === 'htmlmixed' || mode === 'html') { 19 | return html(content); 20 | } 21 | 22 | if (mode === 'javascript') { 23 | return js(content); 24 | } 25 | 26 | return content; 27 | } 28 | 29 | export default { 30 | html, 31 | js, 32 | format, 33 | }; 34 | -------------------------------------------------------------------------------- /src/lib/deepEqual.js: -------------------------------------------------------------------------------- 1 | function deepEqual(left, right) { 2 | if (left === right) { 3 | return true; 4 | } 5 | 6 | if (Array.isArray(left)) { 7 | return ( 8 | Array.isArray(right) && 9 | left.length === right.length && 10 | left.every((value, idx) => deepEqual(value, right[idx])) 11 | ); 12 | } 13 | 14 | if (left && typeof left === 'object') { 15 | return ( 16 | right && 17 | typeof right === 'object' && 18 | Object.keys(left).length === Object.keys(right).length && 19 | Object.entries(left).every(([key, value]) => deepEqual(value, right[key])) 20 | ); 21 | } 22 | 23 | if (left instanceof Date) { 24 | return right instanceof Date && left.getTime() === right.getTime(); 25 | } 26 | 27 | return false; 28 | } 29 | 30 | export default deepEqual; 31 | -------------------------------------------------------------------------------- /src/lib/domEvents.js: -------------------------------------------------------------------------------- 1 | import { eventMap } from '@testing-library/dom/dist/event-map'; 2 | 3 | export function addLoggingEvents(node, log) { 4 | function createEventLogger(eventType) { 5 | return function logEvent(event) { 6 | if (event.target === event.currentTarget) { 7 | return; 8 | } 9 | 10 | log({ 11 | event: eventType, 12 | target: getElementData(event.target), 13 | }); 14 | }; 15 | } 16 | const eventListeners = []; 17 | Object.keys(eventMap).forEach((name) => { 18 | eventListeners.push({ 19 | name: name.toLowerCase(), 20 | listener: node.addEventListener( 21 | name.toLowerCase(), 22 | createEventLogger({ name, ...eventMap[name] }), 23 | true, 24 | ), 25 | }); 26 | }); 27 | 28 | return eventListeners; 29 | } 30 | 31 | export function getElementData(element) { 32 | const value = 33 | element.tagName === 'SELECT' && element.multiple 34 | ? element.selectedOptions.length > 0 35 | ? JSON.stringify( 36 | Array.from(element.selectedOptions).map((o) => o.value), 37 | ) 38 | : null 39 | : element.value; 40 | 41 | const hasChecked = element.type === 'checkbox' || element.type === 'radio'; 42 | 43 | return { 44 | tagName: element.tagName.toLowerCase(), 45 | id: element.id || null, 46 | name: element.name || null, 47 | htmlFor: element.htmlFor || null, 48 | value: value || null, 49 | checked: hasChecked ? !!element.checked : null, 50 | toString: targetToString, 51 | }; 52 | } 53 | 54 | export function targetToString() { 55 | return [ 56 | this.tagName.toLowerCase(), 57 | this.id && `#${this.id}`, 58 | this.name && `[name="${this.name}"]`, 59 | this.htmlFor && `[for="${this.htmlFor}"]`, 60 | this.value && `[value="${this.value}"]`, 61 | this.checked !== null && `[checked=${this.checked}]`, 62 | ] 63 | .filter(Boolean) 64 | .join(''); 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/ensureArray.js: -------------------------------------------------------------------------------- 1 | export function ensureArray(collection) { 2 | return collection instanceof NodeList 3 | ? Array.from(collection) 4 | : Array.isArray(collection) 5 | ? collection 6 | : [collection]; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/getFieldName.js: -------------------------------------------------------------------------------- 1 | export function wrapInQuotes(val) { 2 | if (!val.includes(`'`)) { 3 | return `'${val}'`; 4 | } 5 | 6 | if (!val.includes('"')) { 7 | return `"${val}"`; 8 | } 9 | 10 | if (!val.includes('`')) { 11 | return `\`${val}\``; 12 | } 13 | 14 | return `'${val.replace(/'/g, `\\'`)}'`; 15 | } 16 | 17 | export function getFieldName(method) { 18 | return method[5].toLowerCase() + method.substr(6); 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | export * from './ensureArray'; 2 | export * from './getFieldName'; 3 | export * from './queryAdvise'; 4 | -------------------------------------------------------------------------------- /src/lib/logger.js: -------------------------------------------------------------------------------- 1 | import differ from 'deep-diff'; 2 | 3 | const styles = { 4 | header: 'color: gray; font-weight: lighter;', 5 | type: 'color: black; font-weight: bold;', 6 | prevState: 'color: #9E9E9E; font-weight: bold;', 7 | action: 'color: #03A9F4; font-weight: bold;', 8 | nextState: 'color: #4CAF50; font-weight: bold;', 9 | error: 'color: #F20404; font-weight: bold;', 10 | black: 'color: #000000; font-weight: bold;', 11 | 12 | diff: { 13 | E: { 14 | style: `color: #2196F3; font-weight: bold`, 15 | text: 'CHANGED:', 16 | }, 17 | N: { 18 | style: `color: #4CAF50; font-weight: bold`, 19 | text: 'ADDED:', 20 | }, 21 | D: { 22 | style: `color: #F44336; font-weight: bold`, 23 | text: 'DELETED:', 24 | }, 25 | A: { 26 | style: `color: #2196F3; font-weight: bold`, 27 | text: 'ARRAY:', 28 | }, 29 | }, 30 | }; 31 | 32 | export const pad = (num, maxLength) => `${num}`.padStart(0, maxLength); 33 | 34 | function omit(keys, object) { 35 | return Object.entries(object).reduce((acc, [key, value]) => { 36 | acc[key] = keys.includes(key) ? '_ignored_' : value; 37 | return acc; 38 | }, {}); 39 | } 40 | 41 | function formatTime(time) { 42 | return `${pad(time.getHours(), 2)}:${pad(time.getMinutes(), 2)}:${pad( 43 | time.getSeconds(), 44 | 2, 45 | )}.${pad(time.getMilliseconds(), 3)}`; 46 | } 47 | 48 | // Use performance API if it's available in order to get better precision 49 | // performance can be null, performance?.now won't work here! 50 | const timer = 51 | (typeof performance || {}).now === 'function' ? performance : Date; 52 | 53 | function renderDiff(diff) { 54 | const { kind, path, lhs, rhs, index, item } = diff; 55 | 56 | switch (kind) { 57 | case 'E': 58 | return [path.join('.'), lhs, '→', rhs]; 59 | case 'N': 60 | return [path.join('.'), rhs]; 61 | case 'D': 62 | return [path.join('.')]; 63 | case 'A': 64 | return [`${path.join('.')}[${index}]`, item]; 65 | default: 66 | return []; 67 | } 68 | } 69 | 70 | // debug can be `false`, `true` or `diff`. On production, logging is disabled 71 | // when debug is `undefined` and can be enabled by setting it to either `true` 72 | // or `diff`. On develop, it's enabled when `undefined`, and can be disabled 73 | // by setting it to `false`. 74 | const debug = navigator.cookieEnabled ? localStorage.getItem('debug') : 'false'; 75 | 76 | const logLevel = debug === 'false' ? false : debug === 'true' ? true : debug; 77 | const isLoggingEnabled = 78 | process.env.NODE_ENV === 'development' ? logLevel !== false : !!logLevel; 79 | const isDiffEnabled = logLevel === 'diff'; 80 | const diffIgnoreKeys = ['markupEditor', 'queryEditor', 'sandbox']; 81 | 82 | export function withLogging(reducerFn) { 83 | if (!isLoggingEnabled) { 84 | return reducerFn; 85 | } 86 | 87 | const supportsGroups = typeof console.groupCollapsed === 'function'; 88 | 89 | return (prevState, action, exec) => { 90 | const started = timer.now(); 91 | const startedTime = new Date(); 92 | 93 | const newState = reducerFn(prevState, action, exec); 94 | 95 | const took = timer.now() - started; 96 | 97 | const header = [ 98 | [ 99 | `%caction`, 100 | `%c${action.type}`, 101 | `%c@ ${formatTime(startedTime)}`, 102 | `(in ${took.toFixed(2)} ms)`, 103 | ].join(' '), 104 | styles.header, 105 | styles.type, 106 | styles.header, 107 | ]; 108 | 109 | if (supportsGroups) { 110 | console.groupCollapsed(...header); 111 | } else { 112 | console.log(...header); 113 | } 114 | 115 | console.log('%c prev state %O', styles.prevState, prevState); 116 | console.log('%c action %O', styles.action, action); 117 | console.log('%c new state %O', styles.nextState, newState); 118 | 119 | if (!isDiffEnabled) { 120 | console.log('%c diff %c _disabled_', styles.prevState, styles.note); 121 | } else { 122 | const diff = differ( 123 | omit(diffIgnoreKeys, prevState), 124 | omit(diffIgnoreKeys, newState), 125 | ); 126 | 127 | if (!diff) { 128 | console.log( 129 | '%c diff %cno state change!', 130 | styles.prevState, 131 | styles.error, 132 | ); 133 | } else { 134 | if (supportsGroups) { 135 | console.groupCollapsed(' diff'); 136 | } else { 137 | console.log('diff'); 138 | } 139 | 140 | diff.forEach((elem) => { 141 | console.log( 142 | `%c ${styles.diff[elem.kind].text}`, 143 | styles.diff[elem.kind].style, 144 | ...renderDiff(elem), 145 | ); 146 | }); 147 | 148 | if (supportsGroups) { 149 | console.groupEnd(); 150 | } 151 | } 152 | } 153 | 154 | if (supportsGroups) { 155 | console.groupEnd(); 156 | } 157 | 158 | return newState; 159 | }; 160 | } 161 | -------------------------------------------------------------------------------- /src/lib/postMessage.js: -------------------------------------------------------------------------------- 1 | function postMessage(target, action) { 2 | if (!target || !target.postMessage) { 3 | return; 4 | } 5 | 6 | target.postMessage( 7 | { 8 | source: 'testing-playground', 9 | ...action, 10 | }, 11 | target.origin, 12 | ); 13 | } 14 | 15 | export default postMessage; 16 | -------------------------------------------------------------------------------- /src/lib/queryAdvise.js: -------------------------------------------------------------------------------- 1 | import { getSuggestedQuery, queries as queryFns } from '@testing-library/dom'; 2 | import cssPath from './cssPath'; 3 | import beautify from './beautify'; 4 | 5 | function flattenDOM(node) { 6 | return [ 7 | node, 8 | ...Array.from(node.children).reduce( 9 | (acc, child) => [...acc, ...flattenDOM(child)], 10 | [], 11 | ), 12 | ]; 13 | } 14 | 15 | function getSnapshot(element) { 16 | const innerItems = flattenDOM(element); 17 | const snapshot = innerItems 18 | .map((el) => { 19 | const suggestion = getSuggestedQuery(el); 20 | return suggestion && `screen.${suggestion.toString()};`; 21 | }) 22 | .filter(Boolean) 23 | .join('\n'); 24 | 25 | return snapshot; 26 | } 27 | 28 | function getAll(rootNode, { queryName, queryArgs }) { 29 | // use queryBy here, we don't want to throw on no-results-found 30 | return queryFns[`queryAllBy${queryName}`](rootNode, ...queryArgs); 31 | } 32 | 33 | function matchesSingleElement(rootNode, query) { 34 | return getAll(rootNode, query)?.length === 1; 35 | } 36 | 37 | /** 38 | * Check if the viewQuery only matches a single element within the rootNode 39 | * and if the elementQuery only matches a single element within the view 40 | * 41 | * @param rootNode HTMLElement 42 | * @param viewQuery QuerySuggestion 43 | * @param elementQuery QuerySuggestion 44 | * @returns {boolean} 45 | */ 46 | function matchesSingleElementInView(rootNode, viewQuery, elementQuery) { 47 | const elements = getAll(rootNode, viewQuery); 48 | 49 | if (elements.length !== 1) { 50 | return false; 51 | } 52 | 53 | return getAll(elements[0], elementQuery).length === 1; 54 | } 55 | 56 | function getCodeSnippet(rootNode, element, elementQuery) { 57 | // query the element on `screen`, if it results in a single element 58 | if (matchesSingleElement(rootNode, elementQuery)) { 59 | return beautify.js(`screen.${elementQuery}`); 60 | } 61 | 62 | // turns out, there are multiple matches. We're going to try to scope 63 | // the query by using the `within` helper method. 64 | let node = element; 65 | while (node && node !== rootNode) { 66 | const view = getSuggestedQuery(node, 'get'); 67 | 68 | if (view && matchesSingleElementInView(rootNode, view, elementQuery)) { 69 | const prop = view.queryName === 'Role' ? view.queryArgs[0] : 'view'; 70 | return beautify.js(` 71 | const ${prop} = screen.${view}; 72 | 73 | within(${prop}).${elementQuery}; 74 | `); 75 | } 76 | 77 | node = node.parentNode; 78 | } 79 | 80 | // can't construct a working query? :/ 81 | return '// sorry, I failed to provide something useful'; 82 | } 83 | 84 | const queryMethods = [ 85 | 'Role', 86 | 'LabelText', 87 | 'PlaceholderText', 88 | 'Text', 89 | 'DisplayValue', 90 | 'AltText', 91 | 'Title', 92 | 'TestId', 93 | ]; 94 | 95 | export function getAllPossibleQueries({ rootNode, element }, source) { 96 | const result = {}; 97 | 98 | for (const method of queryMethods) { 99 | let suggestion = getSuggestedQuery(element, 'get', method); 100 | 101 | if (suggestion) { 102 | suggestion.snippet = getCodeSnippet(rootNode, element, suggestion); 103 | suggestion.excerpt = suggestion.toString(); 104 | 105 | // toString can't be serialized for message transport 106 | delete suggestion.toString; 107 | } 108 | 109 | result[method] = suggestion; 110 | } 111 | 112 | const path = cssPath(element, true).toString(); 113 | 114 | result.Selector = { 115 | queryMethod: 'querySelector', 116 | queryName: 'Selector', 117 | queryArgs: [path], 118 | snippet: `container.querySelector('${path}')`, 119 | excerpt: `querySelector('${path}')`, 120 | // When opening devtools, an initial "selection changed" event is triggered, 121 | // with the `` element as being the "selected node". This causes a 122 | // snapshot to be created for the whole document. Depending on the size of 123 | // the document, this can become quite the expensive computation, with a 124 | // noticeable impact on the browser performance. I've considered to only 125 | // create snapshots for the first 10 nodes (add limit=n to `flattenDOM`), 126 | // but as we don't show snapshots in the devtools, it seemed better to simply 127 | // don't compute them there. 128 | snapshot: source === 'DEVTOOLS' ? undefined : getSnapshot(element), 129 | }; 130 | 131 | return result; 132 | } 133 | -------------------------------------------------------------------------------- /src/lib/queryAdvise.test.js: -------------------------------------------------------------------------------- 1 | import { getAllPossibleQueries } from './queryAdvise'; 2 | 3 | it('[getAllPossibleQueries] should return an object with all possibile queries', () => { 4 | const rootNode = document.createElement('div'); 5 | const element = document.createElement('button'); 6 | rootNode.appendChild(element); 7 | let suggestedQueries = getAllPossibleQueries({ rootNode, element }); 8 | 9 | expect(suggestedQueries).toMatchInlineSnapshot(` 10 | { 11 | "AltText": undefined, 12 | "DisplayValue": undefined, 13 | "LabelText": undefined, 14 | "PlaceholderText": undefined, 15 | "Role": { 16 | "excerpt": "getByRole('button')", 17 | "queryArgs": [ 18 | "button", 19 | ], 20 | "queryMethod": "getByRole", 21 | "queryName": "Role", 22 | "snippet": "screen.getByRole('button')", 23 | "variant": "get", 24 | "warning": "", 25 | }, 26 | "Selector": { 27 | "excerpt": "querySelector('div > button')", 28 | "queryArgs": [ 29 | "div > button", 30 | ], 31 | "queryMethod": "querySelector", 32 | "queryName": "Selector", 33 | "snapshot": "screen.getByRole('button');", 34 | "snippet": "container.querySelector('div > button')", 35 | }, 36 | "TestId": undefined, 37 | "Text": undefined, 38 | "Title": undefined, 39 | } 40 | `); 41 | 42 | rootNode.innerHTML = ` 43 | 44 | 54 | `; 55 | const input = rootNode.querySelector('input'); 56 | suggestedQueries = getAllPossibleQueries({ rootNode, element: input }); 57 | 58 | expect(suggestedQueries).toMatchInlineSnapshot(` 59 | { 60 | "AltText": { 61 | "excerpt": "getByAltText(/enter your username/i)", 62 | "queryArgs": [ 63 | /enter your username/i, 64 | ], 65 | "queryMethod": "getByAltText", 66 | "queryName": "AltText", 67 | "snippet": "screen.getByAltText(/enter your username/i)", 68 | "variant": "get", 69 | "warning": "", 70 | }, 71 | "DisplayValue": { 72 | "excerpt": "getByDisplayValue(/john\\\\-doe/i)", 73 | "queryArgs": [ 74 | /john\\\\-doe/i, 75 | ], 76 | "queryMethod": "getByDisplayValue", 77 | "queryName": "DisplayValue", 78 | "snippet": "screen.getByDisplayValue(/john\\\\-doe/i)", 79 | "variant": "get", 80 | "warning": "", 81 | }, 82 | "LabelText": { 83 | "excerpt": "getByLabelText(/username/i)", 84 | "queryArgs": [ 85 | /username/i, 86 | ], 87 | "queryMethod": "getByLabelText", 88 | "queryName": "LabelText", 89 | "snippet": "screen.getByLabelText(/username/i)", 90 | "variant": "get", 91 | "warning": "", 92 | }, 93 | "PlaceholderText": { 94 | "excerpt": "getByPlaceholderText(/how should i call you\\\\?/i)", 95 | "queryArgs": [ 96 | /how should i call you\\\\\\?/i, 97 | ], 98 | "queryMethod": "getByPlaceholderText", 99 | "queryName": "PlaceholderText", 100 | "snippet": "screen.getByPlaceholderText(/how should i call you\\\\?/i)", 101 | "variant": "get", 102 | "warning": "", 103 | }, 104 | "Role": { 105 | "excerpt": "getByRole('textbox', { name: /username/i })", 106 | "queryArgs": [ 107 | "textbox", 108 | { 109 | "name": /username/i, 110 | }, 111 | ], 112 | "queryMethod": "getByRole", 113 | "queryName": "Role", 114 | "snippet": "screen.getByRole('textbox', { 115 | name: /username/i 116 | })", 117 | "variant": "get", 118 | "warning": "", 119 | }, 120 | "Selector": { 121 | "excerpt": "querySelector('#username')", 122 | "queryArgs": [ 123 | "#username", 124 | ], 125 | "queryMethod": "querySelector", 126 | "queryName": "Selector", 127 | "snapshot": "screen.getByRole('textbox', { name: /username/i });", 128 | "snippet": "container.querySelector('#username')", 129 | }, 130 | "TestId": { 131 | "excerpt": "getByTestId('uname')", 132 | "queryArgs": [ 133 | "uname", 134 | ], 135 | "queryMethod": "getByTestId", 136 | "queryName": "TestId", 137 | "snippet": "screen.getByTestId('uname')", 138 | "variant": "get", 139 | "warning": "", 140 | }, 141 | "Text": undefined, 142 | "Title": { 143 | "excerpt": "getByTitle(/enter your username/i)", 144 | "queryArgs": [ 145 | /enter your username/i, 146 | ], 147 | "queryMethod": "getByTitle", 148 | "queryName": "Title", 149 | "snippet": "screen.getByTitle(/enter your username/i)", 150 | "variant": "get", 151 | "warning": "", 152 | }, 153 | } 154 | `); 155 | }); 156 | -------------------------------------------------------------------------------- /src/lib/state/url.js: -------------------------------------------------------------------------------- 1 | import { 2 | compressToEncodedURIComponent, 3 | decompressFromEncodedURIComponent, 4 | } from 'lz-string'; 5 | import queryString from 'query-string'; 6 | 7 | import beautify from '../beautify'; 8 | 9 | function unindent(string) { 10 | return (string || '').replace(/[ \t]*[\n][ \t]*/g, '\n'); 11 | } 12 | 13 | export function compress({ markup, query }) { 14 | const result = { 15 | markup: compressToEncodedURIComponent(unindent(markup)), 16 | query: compressToEncodedURIComponent(unindent(query)), 17 | }; 18 | 19 | return result; 20 | } 21 | 22 | export function decompress({ markup, query }) { 23 | const result = { 24 | markup: beautify.html(decompressFromEncodedURIComponent(markup || '')), 25 | query: beautify.js(decompressFromEncodedURIComponent(query || '')), 26 | }; 27 | 28 | return result; 29 | } 30 | 31 | function save({ markup, query }) { 32 | const state = compress({ markup, query }); 33 | 34 | const params = queryString.parse(window.location.search); 35 | const search = queryString.stringify({ 36 | ...params, 37 | ...state, 38 | }); 39 | 40 | history.replaceState(null, '', window.location.pathname + '?' + search); 41 | } 42 | 43 | function load() { 44 | // .com?markup=...&query=... 45 | let params = queryString.parse(location.search); 46 | 47 | // .com#markup=...&query=... 48 | if (!params.markup && !params.query) { 49 | params = queryString.parse(location.hash); 50 | } 51 | 52 | if (!params.markup && !params.query) { 53 | return; 54 | } 55 | 56 | const { markup, query } = decompress(params); 57 | // we could call `save({ markup, query })` here to force migration. Should we? 58 | // not migrating them can be confusing, as the url style doesn't update on change 59 | 60 | return { markup, query }; 61 | } 62 | 63 | export default { 64 | save, 65 | load, 66 | }; 67 | -------------------------------------------------------------------------------- /src/pages/Embedded.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import queryString from 'query-string'; 3 | import { useLocation, useParams } from 'react-router-dom'; 4 | import Preview from '../components/Preview'; 5 | import Query from '../components/Query'; 6 | import Result from '../components/Result'; 7 | import MarkupEditor from '../components/MarkupEditor'; 8 | import useParentMessaging from '../hooks/useParentMessaging'; 9 | import usePlayground from '../hooks/usePlayground'; 10 | import Loader from '../components/Loader'; 11 | 12 | import { defaultPanes } from '../constants'; 13 | 14 | const SUPPORTED_PANES = { 15 | markup: true, 16 | preview: true, 17 | query: true, 18 | result: true, 19 | }; 20 | 21 | const styles = { 22 | offscreen: { 23 | position: 'absolute', 24 | left: -300, 25 | width: 100, 26 | }, 27 | }; 28 | 29 | // TODO: we should support readonly mode 30 | function Embedded(props) { 31 | const params = useParams(); 32 | const [state, dispatch] = usePlayground({ 33 | gistId: props.gistId || params.gistId, 34 | gistVersion: props.gistVersion || params.gistVersion, 35 | }); 36 | const { markup, query, result, status } = state; 37 | const isLoading = status === 'loading'; 38 | // props.height because it describes better, params.maxheight because oembed 39 | const height = props.height || params.maxheight || params.height; 40 | 41 | const location = useLocation(); 42 | const searchParams = queryString.parse(location.search); 43 | 44 | const panes = props.panes 45 | ? props.panes 46 | : searchParams.panes 47 | ? searchParams.panes 48 | .split(',') 49 | .map((x) => x.trim()) 50 | .filter((x) => SUPPORTED_PANES[x]) 51 | : defaultPanes; 52 | 53 | // TODO: we should add tabs to handle > 2 panes 54 | const areaCount = panes.length; 55 | 56 | // Yes, it looks like we could compose this like `grid-cols-${n}`, but that way it isn't detectable by purgeCss 57 | const columnClass = 58 | areaCount === 4 59 | ? 'grid-cols-4' 60 | : areaCount === 3 61 | ? 'grid-cols-3' 62 | : areaCount === 2 63 | ? 'grid-cols-2' 64 | : 'grid-cols-1'; 65 | 66 | useEffect(() => { 67 | if (window === top) { 68 | return; 69 | } 70 | 71 | document.body.classList.add('embedded'); 72 | return () => document.body.classList.remove('embedded'); 73 | }, []); 74 | 75 | useParentMessaging(dispatch); 76 | 77 | return ( 78 |
82 | 83 |
90 | {/*the sandbox must always be rendered!*/} 91 | {!panes.includes('preview') && ( 92 |
93 | 99 |
100 | )} 101 | 102 | {panes.map((area, idx) => { 103 | switch (area) { 104 | case 'preview': 105 | return ( 106 | 113 | ); 114 | case 'markup': 115 | return ( 116 | 121 | ); 122 | case 'query': 123 | return ( 124 | 131 | ); 132 | case 'result': 133 | return ( 134 | 139 | ); 140 | default: 141 | return null; 142 | } 143 | })} 144 |
145 |
146 | ); 147 | } 148 | 149 | export default Embedded; 150 | -------------------------------------------------------------------------------- /src/pages/Playground.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import Preview from '../components/Preview'; 4 | import MarkupEditor from '../components/MarkupEditor'; 5 | import usePlayground from '../hooks/usePlayground'; 6 | import Layout from '../components/Layout'; 7 | import Loader from '../components/Loader'; 8 | import PlaygroundPanels from '../components/PlaygroundPanels'; 9 | import { usePreviewEvents } from '../context/PreviewEvents'; 10 | 11 | function Playground() { 12 | const { gistId, gistVersion } = useParams(); 13 | const [state, dispatch] = usePlayground({ gistId, gistVersion }); 14 | const { markup, result, status, dirty, settings } = state; 15 | const { previewRef } = usePreviewEvents(); 16 | 17 | const isLoading = status === 'loading'; 18 | 19 | return ( 20 | 28 | 29 |
35 |
36 |
37 | 38 |
39 | 40 |
41 | 48 |
49 |
50 | 51 | 52 |
53 |
54 | ); 55 | } 56 | 57 | export default Playground; 58 | -------------------------------------------------------------------------------- /src/pages/Playground.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import Playground from '../App'; 4 | 5 | describe('App', () => { 6 | it('should not throw on render', () => { 7 | render(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/sandbox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Testing Playground Sandbox 6 | 7 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/service-worker.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { toast } from 'react-toastify'; 3 | 4 | if (process.env.NODE_ENV === 'production') { 5 | if ('serviceWorker' in navigator) { 6 | const sw = '/sw.js'; 7 | navigator.serviceWorker.register(sw).then((registration) => { 8 | registration.onupdatefound = () => { 9 | const installingWorker = registration.installing; 10 | if (installingWorker == null) { 11 | return; 12 | } 13 | installingWorker.onstatechange = () => { 14 | if ( 15 | installingWorker.state === 'installed' && 16 | navigator.serviceWorker.controller 17 | ) { 18 | toast( 19 |

20 | A new version is available!{' '} 21 | 27 |

, 28 | { 29 | autoClose: false, 30 | }, 31 | ); 32 | } 33 | }; 34 | }; 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/styles/codemirror.css: -------------------------------------------------------------------------------- 1 | @import 'codemirror/lib/codemirror.css'; 2 | @import 'codemirror/theme/dracula.css'; 3 | @import 'codemirror/addon/scroll/simplescrollbars.css'; 4 | @import 'codemirror/addon/hint/show-hint.css'; 5 | 6 | .CodeMirror.cm-s-dracula { 7 | /* border-radius doesn't work on CM without a border :( */ 8 | @apply border-4 border-solid border-gray-800 bg-gray-800 !important; 9 | border-radius: 4px; 10 | 11 | /* this shouldn't be required but I can't get the thing to accept height:100% */ 12 | position: absolute; 13 | top: 0; 14 | bottom: 0; 15 | left: 0; 16 | right: 0; 17 | height: 100%; 18 | } 19 | 20 | .query-editor .CodeMirror.cm-s-dracula { 21 | border-radius: 4px 4px 0 0; 22 | } 23 | 24 | .query-result { 25 | border-radius: 0 0 4px 4px; 26 | } 27 | 28 | .query-result.expanded { 29 | border-radius: 4px; 30 | } 31 | 32 | .CodeMirror.cm-s-dracula .CodeMirror-gutters { 33 | @apply bg-gray-800 !important; 34 | } 35 | 36 | .CodeMirror-overlayscroll-horizontal, 37 | .CodeMirror-simplescroll-horizontal { 38 | background: transparent; 39 | height: 5px; 40 | } 41 | 42 | .CodeMirror-overlayscroll-vertical, 43 | .CodeMirror-simplescroll-vertical { 44 | background: transparent; 45 | width: 5px; 46 | } 47 | 48 | .CodeMirror-scrollbar-filler { 49 | background: transparent; 50 | } 51 | 52 | .CodeMirror-overlayscroll-horizontal div, 53 | .CodeMirror-overlayscroll-vertical div, 54 | .CodeMirror-simplescroll-horizontal div, 55 | .CodeMirror-simplescroll-vertical div { 56 | @apply bg-gray-600; 57 | background-clip: padding-box !important; 58 | border: none; 59 | border-radius: 4px; 60 | } 61 | 62 | .CodeMirror-simplescroll-horizontal div { 63 | width: 100%; 64 | } 65 | 66 | .CodeMirror-simplescroll-vertical div { 67 | height: 100%; 68 | } 69 | 70 | .embedded .CodeMirror.cm-s-dracula { 71 | height: 100%; 72 | } 73 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | @import './app'; 2 | @import './codemirror'; 3 | @import './spinner'; 4 | @import './toggle'; 5 | 6 | :root { 7 | --reach-menu-button: 1; 8 | --reach-dialog: 1; 9 | } 10 | -------------------------------------------------------------------------------- /src/styles/spinner.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | height: 10px; 3 | display: inline-flex; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | 8 | .spinner > div { 9 | background-color: #cbd5e0; 10 | height: 100%; 11 | width: 4px; 12 | margin-right: 2px; 13 | display: inline-block; 14 | 15 | -webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out; 16 | animation: sk-stretchdelay 1.2s infinite ease-in-out; 17 | } 18 | 19 | .spinner .rect2 { 20 | -webkit-animation-delay: -1.1s; 21 | animation-delay: -1.1s; 22 | } 23 | 24 | .spinner .rect3 { 25 | -webkit-animation-delay: -1s; 26 | animation-delay: -1s; 27 | } 28 | 29 | .spinner .rect4 { 30 | -webkit-animation-delay: -0.9s; 31 | animation-delay: -0.9s; 32 | } 33 | 34 | .spinner .rect5 { 35 | -webkit-animation-delay: -0.8s; 36 | animation-delay: -0.8s; 37 | } 38 | 39 | @-webkit-keyframes sk-stretchdelay { 40 | 0%, 41 | 40%, 42 | 100% { 43 | -webkit-transform: scaleY(0.4); 44 | } 45 | 20% { 46 | -webkit-transform: scaleY(1); 47 | } 48 | } 49 | 50 | @keyframes sk-stretchdelay { 51 | 0%, 52 | 40%, 53 | 100% { 54 | transform: scaleY(0.4); 55 | -webkit-transform: scaleY(0.4); 56 | } 57 | 20% { 58 | transform: scaleY(1); 59 | -webkit-transform: scaleY(1); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/styles/toggle.css: -------------------------------------------------------------------------------- 1 | /* for some reason, importing from node_modules suddenly failed */ 2 | 3 | .react-toggle { 4 | touch-action: pan-x; 5 | 6 | display: inline-block; 7 | position: relative; 8 | cursor: pointer; 9 | background-color: transparent; 10 | border: 0; 11 | padding: 0; 12 | 13 | -webkit-touch-callout: none; 14 | -webkit-user-select: none; 15 | -khtml-user-select: none; 16 | -moz-user-select: none; 17 | -ms-user-select: none; 18 | user-select: none; 19 | 20 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 21 | -webkit-tap-highlight-color: transparent; 22 | } 23 | 24 | .react-toggle-screenreader-only { 25 | border: 0; 26 | clip: rect(0 0 0 0); 27 | height: 1px; 28 | margin: -1px; 29 | overflow: hidden; 30 | padding: 0; 31 | position: absolute; 32 | width: 1px; 33 | } 34 | 35 | .react-toggle--disabled { 36 | cursor: not-allowed; 37 | opacity: 0.5; 38 | -webkit-transition: opacity 0.25s; 39 | transition: opacity 0.25s; 40 | } 41 | 42 | .react-toggle-track { 43 | width: 50px; 44 | height: 24px; 45 | padding: 0; 46 | border-radius: 30px; 47 | background-color: #4d4d4d; 48 | -webkit-transition: all 0.2s ease; 49 | -moz-transition: all 0.2s ease; 50 | transition: all 0.2s ease; 51 | } 52 | 53 | .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { 54 | background-color: #000000; 55 | } 56 | 57 | .react-toggle--checked .react-toggle-track { 58 | background-color: #19ab27; 59 | } 60 | 61 | .react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { 62 | background-color: #128d15; 63 | } 64 | 65 | .react-toggle-track-check { 66 | position: absolute; 67 | width: 14px; 68 | height: 10px; 69 | top: 0px; 70 | bottom: 0px; 71 | margin-top: auto; 72 | margin-bottom: auto; 73 | line-height: 0; 74 | left: 8px; 75 | opacity: 0; 76 | -webkit-transition: opacity 0.25s ease; 77 | -moz-transition: opacity 0.25s ease; 78 | transition: opacity 0.25s ease; 79 | } 80 | 81 | .react-toggle--checked .react-toggle-track-check { 82 | opacity: 1; 83 | -webkit-transition: opacity 0.25s ease; 84 | -moz-transition: opacity 0.25s ease; 85 | transition: opacity 0.25s ease; 86 | } 87 | 88 | .react-toggle-track-x { 89 | position: absolute; 90 | width: 10px; 91 | height: 10px; 92 | top: 0px; 93 | bottom: 0px; 94 | margin-top: auto; 95 | margin-bottom: auto; 96 | line-height: 0; 97 | right: 10px; 98 | opacity: 1; 99 | -webkit-transition: opacity 0.25s ease; 100 | -moz-transition: opacity 0.25s ease; 101 | transition: opacity 0.25s ease; 102 | } 103 | 104 | .react-toggle--checked .react-toggle-track-x { 105 | opacity: 0; 106 | } 107 | 108 | .react-toggle-thumb { 109 | transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms; 110 | position: absolute; 111 | top: 1px; 112 | left: 1px; 113 | width: 22px; 114 | height: 22px; 115 | border: 1px solid #4d4d4d; 116 | border-radius: 50%; 117 | background-color: #fafafa; 118 | 119 | -webkit-box-sizing: border-box; 120 | -moz-box-sizing: border-box; 121 | box-sizing: border-box; 122 | 123 | -webkit-transition: all 0.25s ease; 124 | -moz-transition: all 0.25s ease; 125 | transition: all 0.25s ease; 126 | } 127 | 128 | .react-toggle--checked .react-toggle-thumb { 129 | left: 27px; 130 | border-color: #19ab27; 131 | } 132 | 133 | .react-toggle--focus .react-toggle-thumb { 134 | -webkit-box-shadow: 0px 0px 3px 2px #0099e0; 135 | -moz-box-shadow: 0px 0px 3px 2px #0099e0; 136 | box-shadow: 0px 0px 2px 3px #0099e0; 137 | } 138 | 139 | .react-toggle:active:not(.react-toggle--disabled) .react-toggle-thumb { 140 | -webkit-box-shadow: 0px 0px 5px 5px #0099e0; 141 | -moz-box-shadow: 0px 0px 5px 5px #0099e0; 142 | box-shadow: 0px 0px 5px 5px #0099e0; 143 | } 144 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { colors } = require('tailwindcss/defaultTheme'); 2 | 3 | module.exports = { 4 | purge: false, 5 | theme: { 6 | extend: { 7 | colors: { 8 | gray: { 9 | ...colors.gray, 10 | 500: '#728DA7', 11 | }, 12 | }, 13 | }, 14 | }, 15 | variants: { 16 | scale: ['focus', 'hover', 'group-hover'], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /tests/setupTests.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime/runtime'; 2 | import 'jest-extended'; 3 | import '@testing-library/jest-dom'; 4 | 5 | if (window.document) { 6 | window.document.createRange = () => ({ 7 | setStart: () => {}, 8 | setEnd: () => {}, 9 | getBoundingClientRect: () => ({ right: 0 }), 10 | getClientRects: () => [], 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "module": "ES2022", 5 | "target": "ES2022", 6 | "resolveJsonModule": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import { join, resolve } from 'path'; 3 | import { copy } from 'fs-extra'; 4 | import { generateSW } from 'workbox-build'; 5 | import { config } from './scripts/vite.config.mjs'; 6 | import { __DEV__ } from './scripts/utils.mjs'; 7 | 8 | const r = (...path) => resolve(__dirname, ...path); 9 | const hashRegExp = /-[0-9a-fA-F]{8}\./; 10 | 11 | export default defineConfig(() => { 12 | const outDir = r('dist', 'public'); 13 | 14 | const input = [ 15 | r('src', 'index.html'), 16 | r('src', 'sandbox.html'), 17 | __DEV__ && r('src', 'embed.html'), 18 | ].filter(Boolean); 19 | 20 | return { 21 | ...config, 22 | 23 | root: r('src'), 24 | publicDir: r('public'), 25 | 26 | server: { 27 | open: __DEV__, 28 | proxy: { 29 | '/api': { 30 | target: 'http://localhost:8888/.netlify/functions', 31 | changeOrigin: true, 32 | rewrite: (p) => p.replace(/^\/api/, '').replace(/\.js$/, ''), 33 | }, 34 | '^gist/.*': 'http://localhost:5173', 35 | }, 36 | }, 37 | 38 | build: { 39 | outDir, 40 | emptyOutDir: true, 41 | rollupOptions: { 42 | input, 43 | output: { 44 | entryFileNames: '[name].js', 45 | }, 46 | }, 47 | }, 48 | 49 | plugins: [ 50 | ...config.plugins, 51 | { 52 | name: 'post-build', 53 | async generateBundle() { 54 | await Promise.all([ 55 | copy('_redirects', join(outDir, '_redirects')), 56 | copy('.well-known', join(outDir, '.well-known')), 57 | ]); 58 | 59 | await generateSW({ 60 | globDirectory: 'dist/public', 61 | globPatterns: ['**/*.{html,js,css,png,svg,jpg,gif,json,ico}'], 62 | swDest: r('dist/public/sw.js'), 63 | clientsClaim: true, 64 | skipWaiting: true, 65 | manifestTransforms: [ 66 | async function removeRevisionManifestTransform(manifestEntries) { 67 | return { 68 | manifest: manifestEntries.map((entry) => 69 | hashRegExp.test(entry.url) 70 | ? { ...entry, revision: null } 71 | : entry, 72 | ), 73 | }; 74 | }, 75 | ], 76 | ignoreURLParametersMatching: [/.*/], 77 | }); 78 | }, 79 | }, 80 | ], 81 | 82 | test: { 83 | globals: true, 84 | environment: 'jsdom', 85 | setupFiles: '../tests/setupTests', 86 | }, 87 | }; 88 | }); 89 | --------------------------------------------------------------------------------