├── .editorconfig ├── .eslintrc ├── .gitattributes ├── .github ├── labels.yml ├── scripts │ └── security-checker.mjs └── workflows │ ├── check-security-alerts.yml │ ├── handle-labels.yml │ ├── handle-stale.yml │ └── test.yml ├── .gitignore ├── .publishrc ├── Gulpfile.js ├── LICENSE ├── README.md ├── next.config.js ├── package-lock.json ├── package.json ├── src ├── .babelrc ├── index.js.mustache ├── react-15 │ ├── get-root-els.js │ ├── index.js │ └── react-utils.js ├── react-16-18 │ ├── get-root-els.js │ ├── index.js │ └── react-utils.js └── wait-for-react.js ├── test ├── .eslintrc ├── data │ ├── app │ │ ├── index-react-16.html │ │ ├── index-react-17.html │ │ ├── index-react-18.html │ │ ├── index.html │ │ ├── page-without-react.html │ │ ├── root-pure-component.html │ │ ├── src │ │ │ ├── AsyncComponent.jsx │ │ │ ├── app-old-versions.jsx │ │ │ ├── app.jsx │ │ │ ├── main-react-16.jsx │ │ │ ├── main-react-17.jsx │ │ │ ├── main-react-18.jsx │ │ │ ├── root-pure-component.jsx │ │ │ └── stateless-root.jsx │ │ └── stateless-root.html │ └── server-render │ │ └── pages │ │ └── index.js ├── fixtures │ ├── common-tests.js │ ├── server-rendering.js │ └── typescript.ts ├── helpers │ └── service-util.js └── server.js └── ts-defs └── index.d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [{.eslintrc,package.json,.travis.yml}] 15 | indent_size = 2 -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "parserOptions": { 4 | "requireConfigFile": false 5 | }, 6 | "extends": "eslint:recommended", 7 | "rules": { 8 | "no-alert": 2, 9 | "no-array-constructor": 2, 10 | "no-caller": 2, 11 | "no-catch-shadow": 2, 12 | "no-console": 0, 13 | "no-eval": 2, 14 | "no-extend-native": 2, 15 | "no-extra-bind": 2, 16 | "no-implied-eval": 2, 17 | "no-iterator": 2, 18 | "no-label-var": 2, 19 | "no-labels": 2, 20 | "no-lone-blocks": 2, 21 | "no-loop-func": 2, 22 | "no-multi-str": 2, 23 | "no-native-reassign": 2, 24 | "no-new": 2, 25 | "no-new-func": 0, 26 | "no-new-object": 2, 27 | "no-new-wrappers": 2, 28 | "no-octal-escape": 2, 29 | "no-proto": 2, 30 | "no-return-assign": 2, 31 | "no-script-url": 2, 32 | "no-sequences": 2, 33 | "no-shadow": 2, 34 | "no-shadow-restricted-names": 2, 35 | "no-spaced-func": 2, 36 | "no-undef-init": 2, 37 | "no-unused-expressions": 2, 38 | "no-with": 2, 39 | "camelcase": 2, 40 | "comma-spacing": 2, 41 | "consistent-return": 2, 42 | "eqeqeq": 2, 43 | "semi": 2, 44 | "semi-spacing": [2, {"before": false, "after": true}], 45 | "space-infix-ops": 2, 46 | "keyword-spacing": 2, 47 | "space-unary-ops": [2, { "words": true, "nonwords": false }], 48 | "yoda": [2, "never"], 49 | "brace-style": [2, "stroustrup", { "allowSingleLine": false }], 50 | "eol-last": 0, 51 | "indent": 2, 52 | "key-spacing": [2, { "align": "value" }], 53 | "max-nested-callbacks": [2, 3], 54 | "new-parens": 2, 55 | "padding-line-between-statements": [2, 56 | { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*"}, 57 | { "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]} 58 | ], 59 | "no-lonely-if": 2, 60 | "no-multiple-empty-lines": [2, { "max": 2 }], 61 | "no-nested-ternary": 2, 62 | "no-underscore-dangle": 0, 63 | "no-unneeded-ternary": 2, 64 | "object-curly-spacing": [2, "always"], 65 | "operator-assignment": [2, "always"], 66 | "quotes": [2, "single", "avoid-escape"], 67 | "space-before-blocks": [2, "always"], 68 | "prefer-const": 2, 69 | "no-path-concat": 2, 70 | "no-undefined": 2, 71 | "strict": 0, 72 | "curly": [2, "multi-or-nest"], 73 | "dot-notation": 0, 74 | "no-else-return": 2, 75 | "one-var": [2, "never"], 76 | "no-multi-spaces": [2, { 77 | "exceptions": { 78 | "VariableDeclarator": true, 79 | "AssignmentExpression": true 80 | } 81 | }], 82 | "radix": 2, 83 | "no-extra-parens": 2, 84 | "new-cap": [2, { "capIsNew": false }], 85 | "space-before-function-paren": [2, "always"], 86 | "no-use-before-define" : [2, "nofunc"], 87 | "handle-callback-err": 0, 88 | "no-prototype-builtins": 0 89 | }, 90 | "env": { 91 | "node": true, 92 | "es6": true 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # EDITS SHOULD BE SUBMITTED TO DevExpress/testcafe-build-system/config/labels.yml 2 | # Configuration for Label Actions - https://github.com/dessant/label-actions 3 | 4 | ? 'TYPE: question' 5 | : 6 | # Post a comment 7 | comment: | 8 | Thank you for your inquiry. It looks like you're asking a question. We use GitHub to track bug reports and enhancement requests (see [Contributing](https://github.com/DevExpress/testcafe#contributing)). Address your question to the TestCafe community on [StackOverflow](https://stackoverflow.com/questions/ask?tags=testcafe) instead. 9 | 10 | If you encountered a bug, [open a new issue](https://github.com/DevExpress/testcafe/issues/new?template=bug-report.md), and follow the "bug report" template. Thank you in advance. 11 | unlabel: 'STATE: Need response' 12 | close: true 13 | 14 | ? 'STATE: Non-latest version' 15 | : 16 | # Post a comment 17 | comment: | 18 | Thank you for submitting a bug report. It looks like you're using an outdated version of TestCafe. Every TestCafe update contains bug fixes and enhancements. Install [the latest version](https://github.com/DevExpress/testcafe/releases/latest) of TestCafe and see if you can reproduce the bug. We look forward to your response. 19 | label: 'STATE: Need clarification' 20 | unlabel: 21 | - 'STATE: Non-latest version' 22 | - 'STATE: Need response' 23 | 24 | ? 'STATE: Need simple sample' 25 | : 26 | # Post a comment 27 | comment: | 28 | Thank you for submitting a bug report. We would love to help you investigate the issue. Please share a *simple* code example that reliably reproduces the bug. For more information, read the following article: [How To Create a Minimal Working Example When You Submit an Issue](https://testcafe.io/documentation/402636/faq/general-info#how-to-create-a-minimal-working-example-when-you-submit-an-issue). We look forward to your response. 29 | label: 'STATE: Need clarification' 30 | unlabel: 31 | - 'STATE: Need simple sample' 32 | - 'STATE: Need response' 33 | 34 | ? 'STATE: Need access confirmation' 35 | : 36 | # Post a comment 37 | comment: | 38 | Thank you for submitting a bug report. We would love to help you investigate the issue. Unfortunately, we cannot reproduce the bug, because your code example accesses a web resource that requires authentication. 39 | 40 | Please create a [Minimal Example](https://testcafe.io/documentation/402636/faq/general-info#how-to-create-a-minimal-working-example-when-you-submit-an-issue) that works locally or without authentication. Do not share any private data - the DevExpress support team cannot access private resources. We look forward to your response. 41 | 42 | label: 'STATE: Need clarification' 43 | unlabel: 44 | - 'STATE: Need access confirmation' 45 | - 'STATE: Need response' 46 | 47 | ? 'STATE: Incomplete template' 48 | : 49 | # Post a comment 50 | comment: | 51 | Thank you for submitting a bug report. The information you shared is not sufficient to determine the cause of the issue. Please create a new GitHub ticket and fill every section of the "bug report" template. Include the framework's version number, and don't forget to share a [Minimal Working Example](https://testcafe.io/documentation/402636/faq/general-info#how-to-create-a-minimal-working-example-when-you-submit-an-issue) that reliably reproduces the issue. 52 | unlabel: 53 | - 'STATE: Incomplete template' 54 | - 'STATE: Need response' 55 | close: true 56 | 57 | ? 'STATE: No updates' 58 | : 59 | # Post a comment 60 | comment: | 61 | No updates yet. Once we make more progress, we will leave a comment. 62 | unlabel: 63 | - 'STATE: No updates' 64 | - 'STATE: Need response' 65 | 66 | ? 'STATE: No estimations' 67 | : 68 | # Post a comment 69 | comment: | 70 | Personal predictions can be unreliable, so we are not ready to give you an ETA. Once we make more progress, we will leave a comment. 71 | unlabel: 72 | - 'STATE: No estimations' 73 | - 'STATE: Need response' 74 | 75 | ? 'STATE: Outdated proposal' 76 | : 77 | # Post a comment 78 | comment: | 79 | The TestCafe team has yet to allocate any resources for the development of this capability. We cannot give you an ETA on its completion. If this capability is important for you, please submit a Pull Request with an implementation. See the [Сontribution guide](https://github.com/DevExpress/testcafe/blob/master/CONTRIBUTING.md) for more information. 80 | unlabel: 81 | - 'STATE: Outdated proposal' 82 | - 'STATE: Need response' 83 | 84 | ? 'STATE: Outdated issue' 85 | : 86 | # Post a comment 87 | comment: | 88 | When the TestCafe team decides which issues to address, it evaluates their severity, as well as the number of affected users. It appears that the issue you raised is an edge case. 89 | 90 | If this issue is important for you, please submit a Pull Request with a fix. See the [Сontribution guide](https://github.com/DevExpress/testcafe/blob/master/CONTRIBUTING.md) for more information. 91 | unlabel: 92 | - 'STATE: Outdated issue' 93 | - 'STATE: Need response' 94 | 95 | ? 'STATE: No workarounds' 96 | : 97 | # Post a comment 98 | comment: | 99 | There are no workarounds at the moment. We'll leave a comment if we discover a workaround, or fix the bug. 100 | unlabel: 101 | - 'STATE: No workarounds' 102 | - 'STATE: Need response' 103 | 104 | ? 'STATE: PR Review Pending' 105 | : 106 | # Post a comment 107 | comment: | 108 | Thank you for your contribution to TestCafe. When a member of the TestCafe team becomes available, they will review this PR. 109 | unlabel: 110 | - 'STATE: PR Review Pending' 111 | - 'STATE: Need response' 112 | 113 | ? 'STATE: Issue accepted' 114 | : 115 | # Post a comment 116 | comment: | 117 | We appreciate you taking the time to share information about this issue. We reproduced the bug and added this ticket to our internal task queue. We'll update this thread once we have news. 118 | unlabel: 119 | - 'STATE: Issue accepted' 120 | - 'STATE: Need response' 121 | 122 | ? 'STATE: Enhancement accepted' 123 | : 124 | # Post a comment 125 | comment: | 126 | Thank you for bringing this enhancement to our attention. We will be happy to look into it. We'll update this thread once we have news. If we do not publish any new comments, it's safe to assume that there are no new updates. 127 | unlabel: 128 | - 'STATE: Enhancement accepted' 129 | - 'STATE: Need response' 130 | -------------------------------------------------------------------------------- /.github/scripts/security-checker.mjs: -------------------------------------------------------------------------------- 1 | const STATES = { 2 | open: 'open', 3 | closed: 'closed', 4 | }; 5 | 6 | const LABELS = { 7 | dependabot: 'dependabot', 8 | codeq: 'codeql', 9 | security: 'security notification', 10 | }; 11 | 12 | const ALERT_TYPES = { 13 | dependabot: 'dependabot', 14 | codeq: 'codeql', 15 | } 16 | 17 | class SecurityChecker { 18 | constructor(github, context, issueRepo) { 19 | this.github = github; 20 | this.issueRepo = issueRepo; 21 | this.context = { 22 | owner: context.repo.owner, 23 | repo: context.repo.repo, 24 | }; 25 | } 26 | 27 | async check () { 28 | const dependabotAlerts = await this.getDependabotAlerts(); 29 | const codeqlAlerts = await this.getCodeqlAlerts(); 30 | const existedIssues = await this.getExistedIssues(); 31 | 32 | this.alertDictionary = this.createAlertDictionary(existedIssues); 33 | 34 | await this.closeSpoiledIssues(); 35 | await this.createDependabotlIssues(dependabotAlerts); 36 | await this.createCodeqlIssues(codeqlAlerts); 37 | } 38 | 39 | async getDependabotAlerts () { 40 | const { data } = await this.github.rest.dependabot.listAlertsForRepo({ state: STATES.open, ...this.context }); 41 | 42 | return data; 43 | } 44 | 45 | async getCodeqlAlerts () { 46 | try { 47 | const { data } = await this.github.rest.codeScanning.listAlertsForRepo({ state: STATES.open, ...this.context }); 48 | 49 | return data; 50 | } 51 | catch (e) { 52 | if (e.message.includes('no analysis found') || e.message.includes('Advanced Security must be enabled for this repository to use code scanning')) 53 | return []; 54 | 55 | throw e; 56 | } 57 | } 58 | 59 | async getExistedIssues () { 60 | const { data: existedIssues } = await this.github.rest.issues.listForRepo({ 61 | owner: this.context.owner, 62 | repo: this.issueRepo, 63 | labels: [LABELS.security], 64 | state: STATES.open, 65 | }); 66 | 67 | return existedIssues; 68 | } 69 | 70 | createAlertDictionary (existedIssues) { 71 | return existedIssues.reduce((res, issue) => { 72 | const [, url, type] = issue.body.match(/(https:.*\/(dependabot|code-scanning)\/(\d+))/); 73 | 74 | if (!url) 75 | return res; 76 | 77 | if (type === ALERT_TYPES.dependabot) { 78 | const [, cveId] = issue.body.match(/CVE ID:\s*`(.*)`/); 79 | const [, ghsaId] = issue.body.match(/GHSA ID:\s*`(.*)`/); 80 | 81 | res.set(issue.title, { issue, type, cveId, ghsaId }); 82 | } 83 | else 84 | res.set(issue.title, { issue, type }); 85 | 86 | return res; 87 | }, new Map()); 88 | } 89 | 90 | async closeSpoiledIssues () { 91 | const regExpAlertNumbers = new RegExp(`(?<=\`${this.context.repo}\` - https:.*/dependabot/)\\d+`,'g'); 92 | 93 | for (const alert of this.alertDictionary.values()) { 94 | 95 | if (alert.type === ALERT_TYPES.dependabot) { 96 | const alertNumbers = alert.issue.body.match(regExpAlertNumbers); 97 | 98 | if (!alertNumbers) 99 | continue; 100 | 101 | const updates = {}; 102 | let changedBody = alert.issue.body; 103 | 104 | for (let alertNumber of alertNumbers) { 105 | const isAlertOpened = await this.isDependabotAlertOpened(alertNumber); 106 | 107 | if (isAlertOpened) 108 | continue; 109 | 110 | changedBody = changedBody.replace(new RegExp(`\\[ \\](?= \`${this.context.repo}\` - https:.*/dependabot/${alertNumber})`), '[x]'); 111 | } 112 | 113 | updates.body = changedBody; 114 | updates.state = !changedBody.match(/\[ \]/) ? STATES.closed : STATES.open; 115 | updates.issue_number = alert.issue.number; 116 | 117 | await this.updateIssue(updates); 118 | } 119 | } 120 | } 121 | 122 | async isDependabotAlertOpened (alertNumber) { 123 | const alert = await this.getDependabotAlertInfo(alertNumber); 124 | 125 | return alert.state === STATES.open; 126 | } 127 | 128 | async getDependabotAlertInfo (alertNumber) { 129 | try { 130 | const { data } = await this.github.rest.dependabot.getAlert({ alert_number: alertNumber, ...this.context }); 131 | 132 | return data; 133 | } 134 | catch (e) { 135 | if (e.message.includes('No alert found for alert number')) 136 | return {}; 137 | 138 | throw e; 139 | } 140 | } 141 | 142 | async updateIssue (updates) { 143 | return this.github.rest.issues.update({ 144 | owner: this.context.owner, 145 | repo: this.issueRepo, 146 | ...updates, 147 | }); 148 | } 149 | 150 | 151 | async createDependabotlIssues (dependabotAlerts) { 152 | for (const alert of dependabotAlerts) { 153 | if (this.needAddAlertToIssue(alert)) { 154 | await this.addAlertToIssue(alert); 155 | } 156 | else if (this.needCreateIssue(alert)) { 157 | await this.createIssue({ 158 | labels: [LABELS.dependabot, LABELS.security, alert.dependency.scope], 159 | originRepo: this.context.repo, 160 | summary: alert.security_advisory.summary, 161 | description: alert.security_advisory.description, 162 | link: alert.html_url, 163 | issuePackage: alert.dependency.package.name, 164 | cveId: alert.security_advisory.cve_id, 165 | ghsaId: alert.security_advisory.ghsa_id, 166 | }); 167 | } 168 | } 169 | } 170 | 171 | needAddAlertToIssue (alert) { 172 | const regExpAlertNumber = new RegExp(`(?<=\`${this.context.repo}\` - https:.*/dependabot/)${alert.html_url.match(/(?<=https:.*\/)\d+/)}`); 173 | const existedIssue = this.alertDictionary.get(alert.security_advisory.summary); 174 | const alertNumber = existedIssue?.issue.body.match(regExpAlertNumber); 175 | const isAlertExisted = existedIssue?.issue.body.includes(`\`${this.context.repo}\``); 176 | 177 | return existedIssue 178 | && existedIssue.cveId === alert.security_advisory.cve_id 179 | && existedIssue.ghsaId === alert.security_advisory.ghsa_id 180 | && (!isAlertExisted || (isAlertExisted && !alertNumber)); 181 | } 182 | 183 | async addAlertToIssue (alert) { 184 | const updates = {}; 185 | const { issue } = this.alertDictionary.get(alert.security_advisory.summary); 186 | 187 | updates.issue_number = issue.number; 188 | updates.body = issue.body.replace(/(?<=Repositories:)[\s\S]*?(?=####|$)/g, (match) => { 189 | return match + `- [ ] \`${this.context.repo}\` - ${alert.html_url}\n`; 190 | }); 191 | 192 | await this.updateIssue(updates); 193 | } 194 | 195 | async createCodeqlIssues (codeqlAlerts) { 196 | for (const alert of codeqlAlerts) { 197 | if (!this.needCreateIssue(alert, false)) 198 | continue; 199 | 200 | await this.createIssue({ 201 | labels: [LABELS.codeql, LABELS.security], 202 | originRepo: this.context.repo, 203 | summary: alert.rule.description, 204 | description: alert.most_recent_instance.message.text, 205 | link: alert.html_url, 206 | }, false); 207 | } 208 | } 209 | 210 | needCreateIssue (alert, isDependabotAlert = true) { 211 | const dictionaryKey = isDependabotAlert ? alert.security_advisory.summary : `[${this.context.repo}] ${alert.rule.description}`; 212 | 213 | return !this.alertDictionary.get(dictionaryKey) && Date.now() - new Date(alert.created_at) <= 1000 * 60 * 60 * 24; 214 | } 215 | 216 | async createIssue ({ labels, originRepo, summary, description, link, issuePackage = '', cveId, ghsaId }, isDependabotAlert = true) { 217 | const title = isDependabotAlert ? `${summary}` : `[${originRepo}] ${summary}`; 218 | let body = '' 219 | + `#### Repositories:\n` 220 | + `- [ ] \`${originRepo}\` - ${link}\n` 221 | + (issuePackage ? `#### Package: \`${issuePackage}\`\n` : '') 222 | + `#### Description:\n` 223 | + `${description}\n`; 224 | 225 | if (isDependabotAlert) 226 | body += `\n#### CVE ID: \`${cveId}\`\n#### GHSA ID: \`${ghsaId}\``; 227 | 228 | return this.github.rest.issues.create({ 229 | title, body, labels, 230 | owner: this.context.owner, 231 | repo: this.issueRepo, 232 | }); 233 | } 234 | } 235 | 236 | export default SecurityChecker; 237 | -------------------------------------------------------------------------------- /.github/workflows/check-security-alerts.yml: -------------------------------------------------------------------------------- 1 | name: Check security alerts 2 | 3 | on: 4 | schedule: 5 | - cron: "30 1 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | check: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: latest 16 | - uses: actions/github-script@v7 17 | with: 18 | github-token: ${{ secrets.ACTIVE_TOKEN }} 19 | script: | 20 | const {default: SecurityChecker} = await import('${{ github.workspace }}/.github/scripts/security-checker.mjs') 21 | 22 | const securityChecker = new SecurityChecker(github, context, '${{secrets.SECURITY_ISSUE_REPO}}'); 23 | 24 | await securityChecker.check(); 25 | 26 | keepalive-job: 27 | name: Keepalive Workflow 28 | if: ${{ always() }} 29 | runs-on: ubuntu-latest 30 | permissions: 31 | contents: write 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: gautamkrishnar/keepalive-workflow@v2 35 | with: 36 | gh_token: ${{ secrets.ACTIVE_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/handle-labels.yml: -------------------------------------------------------------------------------- 1 | name: 'Label Actions' 2 | 3 | on: 4 | issues: 5 | types: [labeled, unlabeled] 6 | pull_request_target: 7 | types: [labeled, unlabeled] 8 | 9 | jobs: 10 | reaction: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: DevExpress/testcafe-build-system/actions/handle-labels@main 14 | -------------------------------------------------------------------------------- /.github/workflows/handle-stale.yml: -------------------------------------------------------------------------------- 1 | name: "Mark stale issues and pull requests" 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | workflow_dispatch: 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v3 11 | with: 12 | stale-issue-message: "This issue has been automatically marked as stale because it has not had any activity for a long period. It will be closed and archived if no further activity occurs. However, we may return to this issue in the future. If it still affects you or you have any additional information regarding it, please leave a comment and we will keep it open." 13 | stale-pr-message: "This pull request has been automatically marked as stale because it has not had any activity for a long period. It will be closed and archived if no further activity occurs. However, we may return to this pull request in the future. If it is still relevant or you have any additional information regarding it, please leave a comment and we will keep it open." 14 | close-issue-message: "We're closing this issue after a prolonged period of inactivity. If it still affects you, please add a comment to this issue with up-to-date information. Thank you." 15 | close-pr-message: "We're closing this pull request after a prolonged period of inactivity. If it is still relevant, please ask for this pull request to be reopened. Thank you." 16 | stale-issue-label: "STATE: Stale" 17 | stale-pr-label: "STATE: Stale" 18 | days-before-stale: 180 19 | days-before-close: 10 20 | exempt-issue-labels: "AREA: docs,FREQUENCY: critical,FREQUENCY: level 2,HELP WANTED,!IMPORTANT!,STATE: Need clarification,STATE: Need response,STATE: won't fix,support center" 21 | exempt-pr-labels: "AREA: docs,FREQUENCY: critical,FREQUENCY: level 2,HELP WANTED,!IMPORTANT!,STATE: Need clarification,STATE: Need response,STATE: won't fix,support center" 22 | 23 | keepalive-job: 24 | name: Keepalive Workflow 25 | if: ${{ always() }} 26 | runs-on: ubuntu-latest 27 | permissions: 28 | contents: write 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: gautamkrishnar/keepalive-workflow@v2 32 | with: 33 | gh_token: ${{ secrets.ACTIVE_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request_target: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | environment: CI 13 | 14 | env: 15 | DISPLAY: ':99.0' 16 | 17 | steps: 18 | - run: | 19 | sudo apt install fluxbox 20 | Xvfb :99.0 -screen 0 1920x1080x24 & 21 | sleep 3 22 | fluxbox >/dev/null 2>&1 & 23 | 24 | - uses: DevExpress/testcafe-build-system/actions/prepare@main 25 | with: 26 | node-version: '22' 27 | 28 | - run: npm ci --legacy-peer-deps 29 | - run: npm run test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows image file caches 2 | Thumbs.db 3 | ehthumbs.db 4 | 5 | # Folder config file 6 | Desktop.ini 7 | 8 | # Recycle Bin used on file shares 9 | $RECYCLE.BIN/ 10 | 11 | # Windows Installer files 12 | *.cab 13 | *.msi 14 | *.msm 15 | *.msp 16 | 17 | # Windows shortcuts 18 | *.lnk 19 | 20 | # ========================= 21 | # Operating System Files 22 | # ========================= 23 | 24 | # OSX 25 | # ========================= 26 | 27 | .DS_Store 28 | .AppleDouble 29 | .LSOverride 30 | 31 | # Thumbnails 32 | ._* 33 | 34 | # Files that might appear in the root of a volume 35 | .DocumentRevisions-V100 36 | .fseventsd 37 | .Spotlight-V100 38 | .TemporaryItems 39 | .Trashes 40 | .VolumeIcon.icns 41 | 42 | # Directories potentially created on remote AFP share 43 | .AppleDB 44 | .AppleDesktop 45 | Network Trash Folder 46 | Temporary Items 47 | .apdisk 48 | 49 | node_modules/* 50 | .idea/* 51 | lib/* 52 | test/data/lib/* 53 | test/data/server-render/.next 54 | yarn.lock 55 | -------------------------------------------------------------------------------- /.publishrc: -------------------------------------------------------------------------------- 1 | { 2 | "validations": { 3 | "vulnerableDependencies": false, 4 | "uncommittedChanges": true, 5 | "untrackedFiles": true, 6 | "sensitiveData": true, 7 | "branch": "master", 8 | "gitTag": true 9 | }, 10 | "confirm": true, 11 | "publishTag": "latest", 12 | "prePublishScript": "npm test" 13 | } -------------------------------------------------------------------------------- /Gulpfile.js: -------------------------------------------------------------------------------- 1 | const createTestCafe = require('testcafe'); 2 | const del = require('del'); 3 | const eslint = require('gulp-eslint-new'); 4 | const fs = require('fs'); 5 | const glob = require('glob'); 6 | const gulp = require('gulp'); 7 | const mustache = require('gulp-mustache'); 8 | const pathJoin = require('path').join; 9 | const rename = require('gulp-rename'); 10 | const startTestServer = require('./test/server'); 11 | const { promisify } = require('util'); 12 | const nextBuild = require('next/dist/build').default; 13 | const { createServer } = require('vite'); 14 | 15 | const listFiles = promisify(glob); 16 | const deleteFiles = promisify(del); 17 | 18 | let devServer = null; 19 | 20 | gulp.task('clean', () => { 21 | return deleteFiles([ 22 | 'lib', 23 | 'test/data/lib' 24 | ]); 25 | }); 26 | 27 | gulp.task('lint', () => { 28 | return gulp 29 | .src([ 30 | 'src/**/*.js', 31 | 'test/**/*.js', 32 | '!test/data/**', 33 | 'Gulpfile.js' 34 | ]) 35 | .pipe(eslint()) 36 | .pipe(eslint.format()) 37 | .pipe(eslint.failAfterError()); 38 | }); 39 | 40 | gulp.task('build-selectors-script', () => { 41 | function loadModule (modulePath) { 42 | return fs.readFileSync(modulePath).toString(); 43 | } 44 | 45 | return gulp.src('./src/index.js.mustache') 46 | .pipe(mustache({ 47 | getRootElsReact15: loadModule('./src/react-15/get-root-els.js'), 48 | getRootElsReact16to18: loadModule('./src/react-16-18/get-root-els.js'), 49 | 50 | selectorReact15: loadModule('./src/react-15/index.js'), 51 | selectorReact16to18: loadModule('./src/react-16-18/index.js'), 52 | 53 | react15Utils: loadModule('./src/react-15/react-utils.js'), 54 | react16to18Utils: loadModule('./src/react-16-18/react-utils.js'), 55 | 56 | waitForReact: loadModule('./src/wait-for-react.js') 57 | })) 58 | .pipe(rename('index.js')) 59 | .pipe(gulp.dest('lib')); 60 | }); 61 | 62 | gulp.task('clean-build-tmp-resources', () => { 63 | return deleteFiles(['lib/tmp']); 64 | }); 65 | 66 | gulp.task('build-nextjs-app', () => { 67 | const appPath = pathJoin(__dirname, './test/data/server-render'); 68 | 69 | return nextBuild(appPath, require('./next.config.js')); 70 | }); 71 | 72 | gulp.task('build', gulp.series('clean', 'lint', 'build-selectors-script', 'clean-build-tmp-resources')); 73 | 74 | gulp.task('start-dev-server', async () => { 75 | const src = 'test/data/app'; 76 | 77 | devServer = await createServer({ 78 | configFile: false, 79 | root: src, 80 | 81 | server: { 82 | port: 3000 83 | } 84 | }); 85 | 86 | await devServer.listen(); 87 | }); 88 | 89 | 90 | gulp.task('run-tests', async cb => { 91 | const files = await listFiles('test/fixtures/**/*.{js,ts}'); 92 | 93 | await startTestServer(); 94 | 95 | const testCafe = await createTestCafe('localhost', 1337, 1338); 96 | 97 | await testCafe.createRunner() 98 | .src(files) 99 | .browsers(['chrome', 'firefox', 'edge']) 100 | .reporter('list') 101 | .run({ quarantineMode: true, debugOnFail: false }) 102 | .then(failed => { 103 | devServer.close(); 104 | 105 | cb(); 106 | process.exit(failed); 107 | }); 108 | }); 109 | 110 | gulp.task('test', gulp.series('build', 'start-dev-server', 'build-nextjs-app', 'run-tests')); 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (C) 2012-2021 Developer Express Inc. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # testcafe-react-selectors 2 | 3 | This plugin provides selector extensions that make it easier to test ReactJS components with [TestCafe](https://github.com/DevExpress/testcafe). These extensions allow you to select page elements in a way that is native to React. 4 | 5 | ## Install 6 | 7 | `$ npm install testcafe-react-selectors` 8 | 9 | ## Usage 10 | 11 | * [Wait for application to be ready to run tests](#wait-for-application-to-be-ready-to-run-tests) 12 | * [Creating selectors for ReactJS components](#creating-selectors-for-reactjs-components) 13 | * [Selecting elements by the component name](#selecting-elements-by-the-component-name) 14 | * [Selecting nested components](#selecting-nested-components) 15 | * [Selecting components by the component key](#selecting-components-by-the-component-key) 16 | * [Selecting components by display name](#selecting-components-by-display-name) 17 | * [Selecting components by property values](#selecting-components-by-property-values) 18 | * [Properties whose values are objects](#properties-whose-values-are-objects) 19 | * [Searching for nested components](#searching-for-nested-components) 20 | * [Combining with regular TestCafe selectors](#combining-with-regular-testcafe-selectors) 21 | * [Obtaining component's props and state](#obtaining-components-props-and-state) 22 | * [TypeScript Generic Selector](#typescript-generic-selector) 23 | * [Composite Types in Props and State](#composite-types-in-props-and-state) 24 | * [Limitations](#limitations) 25 | 26 | ### Wait for application to be ready to run tests 27 | 28 | To wait until the React's component tree is loaded, add the `waitForReact` method to fixture's `beforeEach` hook. 29 | 30 | ```js 31 | import { waitForReact } from 'testcafe-react-selectors'; 32 | 33 | fixture `App tests` 34 | .page('http://react-app-url') 35 | .beforeEach(async () => { 36 | await waitForReact(); 37 | }); 38 | ``` 39 | 40 | The default timeout for `waitForReact` is `10000` ms. You can specify a custom timeout value: 41 | 42 | ```js 43 | await waitForReact(5000); 44 | ``` 45 | 46 | If you need to call a selector from a Node.js callback, pass the current test controller as the second argument in the `waitForReact` function: 47 | 48 | ```js 49 | import { waitForReact } from 'testcafe-react-selectors'; 50 | 51 | fixture `App tests` 52 | .page('http://react-app-url') 53 | .beforeEach(async t => { 54 | await waitForReact(5000, t); 55 | }); 56 | ``` 57 | 58 | The test controller will be assigned to the [boundTestRun](https://devexpress.github.io/testcafe/documentation/test-api/obtaining-data-from-the-client/#optionsboundtestrun) function's option. Otherwise, TestCafe would throw the following error: `ClientFunction cannot implicitly resolve the test run in context of which it should be executed`. See the [TestCafe documentation](https://devexpress.github.io/testcafe/documentation/test-api/obtaining-data-from-the-client/#calling-client-functions-from-nodejs-callbacks) for further details. 59 | 60 | ### Creating selectors for ReactJS components 61 | 62 | `ReactSelector` allows you to select page elements by the name of the component class or the nested component element. 63 | 64 | Suppose you have the following JSX. 65 | 66 | ```xml 67 | 68 | 69 | 70 | Item 1 71 | Item 2 72 | 73 | 74 |
Items count: {this.state.itemCount}
75 |
76 | ``` 77 | 78 | #### Selecting elements by the component name 79 | 80 | To get a root DOM element for a component, pass the component name to the `ReactSelector` constructor. 81 | 82 | ```js 83 | import { ReactSelector } from 'testcafe-react-selectors'; 84 | 85 | const todoInput = ReactSelector('TodoInput'); 86 | ``` 87 | 88 | #### Selecting nested components 89 | 90 | To obtain a nested component or DOM element, you can use a combined selector or add DOM element's tag name. 91 | 92 | ```js 93 | import { ReactSelector } from 'testcafe-react-selectors'; 94 | 95 | const TodoList = ReactSelector('TodoApp TodoList'); 96 | const itemsCountStatus = ReactSelector('TodoApp div'); 97 | const itemsCount = ReactSelector('TodoApp div span'); 98 | ``` 99 | 100 | Warning: if you specify a DOM element's tag name, React selectors search for the element among the component's children without looking into nested components. For instance, for the JSX above the `ReactSelector('TodoApp div')` selector will be equal to `Selector('.todo-app > div')`. 101 | 102 | #### Selecting components by the component key 103 | 104 | To obtain a component by its key, use the `withKey` method. 105 | 106 | ```js 107 | import { ReactSelector } from 'testcafe-react-selectors'; 108 | 109 | const item = ReactSelector('TodoItem').withKey('HighPriority'); 110 | ``` 111 | 112 | #### Selecting components by display name 113 | 114 | You can select elements by the component's [displayName](https://reactjs.org/docs/react-component.html#displayname). 115 | 116 | For instance, consider the `TodoList` component whose `displayName` class property is specified as follows: 117 | 118 | ```js 119 | class TodoList extends React.Component { 120 | // ... 121 | } 122 | 123 | TodoList.displayName = 'TodoList'; 124 | ``` 125 | 126 | In this instance, you can use `todo-list-display-name` to identify `TodoList`. 127 | 128 | ```js 129 | import { ReactSelector } from 'testcafe-react-selectors'; 130 | 131 | const list = ReactSelector('todo-list-display-name'); 132 | ``` 133 | 134 | #### Selecting components by property values 135 | 136 | React selectors allow you to select elements that have a specific property value. To do this, use the `withProps` method. You can pass the property and its value as two parameters or an object. 137 | 138 | ```js 139 | import { ReactSelector } from 'testcafe-react-selectors'; 140 | 141 | const item1 = ReactSelector('TodoApp').withProps('priority', 'High'); 142 | const item2 = ReactSelector('TodoApp').withProps({ priority: 'Low' }); 143 | ``` 144 | 145 | You can also search for elements by multiple properties. 146 | 147 | ```js 148 | import { ReactSelector } from 'testcafe-react-selectors'; 149 | 150 | const element = ReactSelector('componentName').withProps({ 151 | propName: 'value', 152 | anotherPropName: 'differentValue' 153 | }); 154 | ``` 155 | 156 | ##### Properties whose values are objects 157 | 158 | React selectors allow you to filter components by properties whose values are objects. 159 | 160 | When the `withProps` function filters properties, it determines whether the objects (property values) are strictly or partially equal. 161 | 162 | The following example illustrates strict and partial equality. 163 | 164 | ```js 165 | object1 = { 166 | field1: 1 167 | } 168 | object2 = { 169 | field1: 1 170 | } 171 | object3 = { 172 | field1: 1 173 | field2: 2 174 | } 175 | object4 = { 176 | field1: 3 177 | field2: 2 178 | } 179 | ``` 180 | 181 | * `object1` strictly equals `object2` 182 | * `object2` partially equals `object3` 183 | * `object2` does not equal `object4` 184 | * `object3` does not equal `object4` 185 | 186 | Prior to version 3.0.0, `withProps` checked if objects are strictly equal when comparing them. Since 3.0.0, `withProps` checks for partial equality. To test objects for strict equality, specify the `exactObjectMatch` option. 187 | 188 | The following example returns the `componentName` component because the `objProp` property values are strictly equal and `exactObjectMatch` is set to true. 189 | 190 | ```js 191 | // props = { 192 | // simpleProp: 'value', 193 | // objProp: { 194 | // field1: 'value', 195 | // field2: 'value' 196 | // } 197 | // } 198 | 199 | const element = ReactSelector('componentName').withProps({ 200 | simpleProp: 'value', 201 | objProp: { 202 | field1: 'value', 203 | field2: 'value' 204 | } 205 | }, { exactObjectMatch: true }) 206 | ``` 207 | 208 | Note that the partial equality check works for objects of any depth. 209 | 210 | ```js 211 | // props = { 212 | // simpleProp: 'value', 213 | // objProp: { 214 | // field1: 'value', 215 | // field2: 'value', 216 | // nested1: { 217 | // someField: 'someValue', 218 | // nested2: { 219 | // someField: 'someValue', 220 | // nested3: { 221 | // field: 'value', 222 | // someField: 'someValue' 223 | // } 224 | // } 225 | // } 226 | // } 227 | // } 228 | 229 | 230 | const element = ReactSelector('componentName').withProps({ 231 | simpleProp: 'value', 232 | objProp: { 233 | field1: 'value', 234 | nested1: { 235 | nested2: { 236 | nested3: { 237 | field: 'value' 238 | } 239 | } 240 | } 241 | } 242 | }, { exactObjectMatch: false }) 243 | ``` 244 | 245 | #### Searching for nested components 246 | 247 | You can search for a desired subcomponent or DOM element among the component's children using the `.findReact(element)` method. The method takes the subcomponent name or tag name as a parameter. 248 | 249 | Suppose you have the following JSX. 250 | 251 | ```xml 252 | 253 |
254 | 255 | Item 1 256 | Item 2 257 | 258 |
259 |
260 | ``` 261 | 262 | The following sample demonstrates how to obtain the `TodoItem` subcomponent. 263 | 264 | ```js 265 | import { ReactSelector } from 'testcafe-react-selectors'; 266 | 267 | const component = ReactSelector('TodoApp'); 268 | const div = component.findReact('div'); 269 | const subComponent = div.findReact('TodoItem'); 270 | ``` 271 | 272 | You can call the `.findReact` method in a chain, for example: 273 | 274 | ```js 275 | import { ReactSelector } from 'testcafe-react-selectors'; 276 | 277 | const subComponent = ReactSelector('TodoApp').findReact('div').findReact('TodoItem'); 278 | ``` 279 | 280 | You can also combine `.findReact` with regular selectors and [other](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#functional-style-selectors)) methods like [.find](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#find) or [.withText](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#withtext), for example: 281 | 282 | ```js 283 | import { ReactSelector } from 'testcafe-react-selectors'; 284 | 285 | const subComponent = ReactSelector('TodoApp').find('div').findReact('TodoItem'); 286 | ``` 287 | 288 | #### Combining with regular TestCafe selectors 289 | 290 | Selectors returned by the `ReactSelector` constructor are recognized as TestCafe selectors. You can combine them with regular selectors and filter with [.withText](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#withtext), [.nth](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#nth), [.find](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#find) and [other](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/selectors.html#functional-style-selectors) functions. To search for elements within a component, you can use the following combined approach. 291 | 292 | ```js 293 | import { ReactSelector } from 'testcafe-react-selectors'; 294 | 295 | var itemsCount = ReactSelector('TodoApp').find('.items-count span'); 296 | ``` 297 | 298 | **Example** 299 | 300 | Let's use the API described above to add a task to a Todo list and check that the number of items changed. 301 | 302 | ```js 303 | import { ReactSelector } from 'testcafe-react-selectors'; 304 | 305 | fixture `TODO list test` 306 | .page('http://localhost:1337'); 307 | 308 | test('Add new task', async t => { 309 | const todoTextInput = ReactSelector('TodoInput'); 310 | const todoItem = ReactSelector('TodoList TodoItem'); 311 | 312 | await t 313 | .typeText(todoTextInput, 'My Item') 314 | .pressKey('enter') 315 | .expect(todoItem.count).eql(3); 316 | }); 317 | ``` 318 | 319 | ### Obtaining component's props and state 320 | 321 | As an alternative to [testcafe snapshot properties](http://devexpress.github.io/testcafe/documentation/test-api/selecting-page-elements/dom-node-state.html), you can obtain `state`, `props` or `key` of a ReactJS component. 322 | 323 | To obtain component's properties, state and key, use the React selector's `.getReact()` method. 324 | 325 | The `.getReact()` method returns a [client function](https://devexpress.github.io/testcafe/documentation/test-api/obtaining-data-from-the-client.html). This function resolves to an object that contains component's properties (excluding properties of its `children`), state and key. 326 | 327 | ```js 328 | const reactComponent = ReactSelector('MyComponent'); 329 | const reactComponentState = await reactComponent.getReact(); 330 | 331 | // >> reactComponentState 332 | // 333 | // { 334 | // props: , 335 | // state: , 336 | // key: 337 | // } 338 | ``` 339 | 340 | The returned client function can be passed to assertions activating the [Smart Assertion Query mechanism](https://devexpress.github.io/testcafe/documentation/test-api/assertions/#smart-assertion-query-mechanism). 341 | 342 | **Example** 343 | 344 | ```js 345 | import { ReactSelector } from 'testcafe-react-selectors'; 346 | 347 | fixture `TODO list test` 348 | .page('http://localhost:1337'); 349 | 350 | test('Check list item', async t => { 351 | const el = ReactSelector('TodoList'); 352 | const component = await el.getReact(); 353 | 354 | await t.expect(component.props.priority).eql('High'); 355 | await t.expect(component.state.isActive).eql(false); 356 | await t.expect(component.key).eql('componentID'); 357 | }); 358 | ``` 359 | 360 | As an alternative, the `.getReact()` method can take a function that returns the required property, state or key. This function acts as a filter. Its argument is an object returned by `.getReact()`, i.e. `{ props: ..., state: ..., key: ...}`. 361 | 362 | ```js 363 | ReactSelector('Component').getReact(({ props, state, key }) => {...}) 364 | ``` 365 | 366 | **Example** 367 | 368 | ```js 369 | import { ReactSelector } from 'testcafe-react-selectors'; 370 | 371 | fixture `TODO list test` 372 | .page('http://localhost:1337'); 373 | 374 | test('Check list item', async t => { 375 | const el = ReactSelector('TodoList'); 376 | 377 | await t 378 | .expect(el.getReact(({ props }) => props.priority)).eql('High') 379 | .expect(el.getReact(({ state }) => state.isActive)).eql(false) 380 | .expect(el.getReact(({ key }) => key)).eql('componentID'); 381 | }); 382 | ``` 383 | 384 | The `.getReact()` method can be called for the `ReactSelector` or the snapshot this selector returns. 385 | 386 | ### TypeScript Generic Selector 387 | 388 | Use the generic `ReactComponent` type to create scalable selectors in TypeScript. 389 | 390 | Pass the `props` object as the type argument to `ReactComponent` to introduce a type for a specific component. 391 | 392 | ```ts 393 | type TodoItem = ReactComponent<{ id: string }>; 394 | ``` 395 | 396 | You can then pass the created `TodoItem` type to the `withProps` and `getReact` generic methods. 397 | 398 | ```ts 399 | const component = ReactSelector('TodoItem'); 400 | type TodoItem = ReactComponent<{ id: string }>; 401 | 402 | const item1 = component.withProps('id', 'tdi-1'); 403 | const itemId = component.getReact(({ props }) => props.id); 404 | ``` 405 | 406 | **Example** 407 | 408 | ``` ts 409 | import { ReactSelector, ReactComponent } from 'testcafe-react-selectors'; 410 | 411 | fixture`typescript support` 412 | .page('http://react-page-example.com') 413 | 414 | test('ReactComponent', async t => { 415 | const todoList = ReactSelector('TodoList'); 416 | type TodoListComponent = ReactComponent<{ id: string }>; 417 | 418 | const todoListId = todoList.getReact(({ props }) => props.id); 419 | 420 | await t.expect(todoListId).eql('ul-item'); 421 | }); 422 | ``` 423 | 424 | #### Composite Types in Props and State 425 | 426 | If a component's props and state include other composite types, you can create your own type definitions for them. Then pass these definitions to `ReactComponent` as type arguments. 427 | 428 | The following example shows custom `Props` and `State` type definitions. The `State` type uses another composite type - `Option`. 429 | 430 | ``` ts 431 | import { ReactComponent } from 'testcafe-react-selectors'; 432 | 433 | interface Props { 434 | id: string; 435 | text: string; 436 | } 437 | 438 | interface Option { 439 | id: number; 440 | title: string; 441 | description: string; 442 | } 443 | 444 | interface State { 445 | optionsCount: number; 446 | options: Option[]; 447 | } 448 | 449 | export type OptionReactComponent = ReactComponent; 450 | ``` 451 | 452 | ### Limitations 453 | 454 | * `testcafe-react-selectors` support ReactJS starting with version 16. To check if a component can be found, use the [react-dev-tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) extension. 455 | * Search for a component starts from the root React component, so selectors like `ReactSelector('body MyComponent')` will return `null`. 456 | * ReactSelectors need class names to select components on the page. Code minification usually does not keep the original class names. So you should either use non-minified code or configure the minificator to keep class names. 457 | 458 | For `babel-minify`, add the following options to the configuration: 459 | 460 | ```js 461 | { keepClassName: true, keepFnName: true } 462 | ``` 463 | 464 | In UglifyJS, use the following configuration: 465 | 466 | ```js 467 | { 468 | compress: { 469 | keep_fnames: true 470 | }, 471 | 472 | mangle: { 473 | keep_fnames: true 474 | } 475 | } 476 | ``` 477 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpack (cfg) { 3 | cfg.optimization = { 4 | minimize: false 5 | }; 6 | 7 | return cfg; 8 | }, 9 | 10 | eslint: { 11 | ignoreDuringBuilds: true 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "testcafe-react-selectors", 3 | "version": "5.0.3", 4 | "description": "ReactJS selectors for TestCafe", 5 | "repository": "https://github.com/DevExpress/testcafe-react-selectors", 6 | "main": "lib/index", 7 | "files": [ 8 | "lib", 9 | "ts-defs" 10 | ], 11 | "author": { 12 | "name": "Developer Express Inc.", 13 | "url": "https://devexpress.com" 14 | }, 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@babel/eslint-parser": "^7.17.0", 18 | "@testcafe/publish-please": "^5.6.0", 19 | "chai": "^3.2.0", 20 | "del": "^1.2.0", 21 | "express": "^4.16.3", 22 | "glob": "^7.1.7", 23 | "gulp": "^4.0.2", 24 | "gulp-eslint-new": "^1.4.2", 25 | "gulp-mustache": "^3.0.1", 26 | "gulp-rename": "^1.2.2", 27 | "next": "^14.1.1", 28 | "react": "npm:react@^18.0.0", 29 | "react-dom": "npm:react-dom@^18.0.0", 30 | "react-dom16": "npm:react-dom@^16.0.0", 31 | "react-dom17": "npm:react-dom@^17.0.0", 32 | "react16": "npm:react@^16.0.0", 33 | "react17": "npm:react@^17.0.0", 34 | "testcafe": "^3.6.2", 35 | "vite": "^3.2.11" 36 | }, 37 | "scripts": { 38 | "test": "gulp test", 39 | "publish-please": "publish-please", 40 | "prepublish": "publish-please guard" 41 | }, 42 | "keywords": [ 43 | "testcafe", 44 | "react", 45 | "selectors", 46 | "plugin" 47 | ], 48 | "peerDependencies": { 49 | "testcafe": ">1.0.0" 50 | }, 51 | "types": "./ts-defs/index.d.ts" 52 | } 53 | -------------------------------------------------------------------------------- /src/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "compact": false, 3 | "presets": [ 4 | ["@babel/preset-env", { 5 | "targets": { 6 | "browsers": ["last 2 versions","IE>=11"] 7 | } 8 | }] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/index.js.mustache: -------------------------------------------------------------------------------- 1 | /*global document window*/ 2 | const { Selector, ClientFunction } = require('testcafe'); 3 | 4 | exports.ReactSelector = Selector(selector => { 5 | const getRootElsReact15 = {{{getRootElsReact15}}} 6 | const getRootElsReact16to18 = {{{getRootElsReact16to18}}} 7 | const selectorReact15 = {{{selectorReact15}}} 8 | const selectorReact16to18 = {{{selectorReact16to18}}} 9 | 10 | let visitedRootEls = []; 11 | let rootEls = null; 12 | 13 | function checkRootNodeVisited (component) { 14 | return visitedRootEls.indexOf(component) > -1; 15 | } 16 | 17 | function defineSelectorProperty (value) { 18 | if (window['%testCafeReactSelector%']) delete window['%testCafeReactSelector%']; 19 | 20 | Object.defineProperty(window, '%testCafeReactSelector%', { 21 | enumerable: false, 22 | configurable: true, 23 | writable: false, 24 | value: value 25 | }); 26 | } 27 | 28 | const react15Utils = {{{react15Utils}}} 29 | const react16to18Utils = {{{react16to18Utils}}} 30 | 31 | if(!window['%testCafeReactSelectorUtils%']) { 32 | window['%testCafeReactSelectorUtils%'] = { 33 | '15' : react15Utils, 34 | '16|17|18': react16to18Utils 35 | }; 36 | } 37 | 38 | rootEls = getRootElsReact15(); 39 | 40 | let foundDOMNodes = void 0; 41 | 42 | if(rootEls.length) { 43 | window['%testCafeReactVersion%'] = '15'; 44 | window['$testCafeReactSelector'] = selectorReact15; 45 | 46 | foundDOMNodes = selectorReact15(selector); 47 | } 48 | 49 | rootEls = getRootElsReact16to18(); 50 | 51 | if(rootEls.length) { 52 | //NOTE: root.return for 16 and 17 version 53 | const rootContainers = rootEls.map(root => root.return || root); 54 | 55 | window['%testCafeReactVersion%'] = '16|17|18'; 56 | window['$testCafeReactSelector'] = selectorReact16to18; 57 | window['$testCafeReact16to18Roots'] = rootEls; 58 | window['$testCafeReact16to18RootContainers'] = rootContainers; 59 | 60 | 61 | foundDOMNodes = selectorReact16to18(selector, false); 62 | } 63 | 64 | visitedRootEls = []; 65 | 66 | if(foundDOMNodes) 67 | return foundDOMNodes; 68 | 69 | throw new Error("React component tree is not loaded yet or the current React version is not supported. This module supports React version 16.x and newer. To wait until the React's component tree is loaded, add the `waitForReact` method to fixture's `beforeEach` hook."); 70 | }).addCustomMethods({ 71 | getReact: (node, fn) => { 72 | const reactVersion = window['%testCafeReactVersion%']; 73 | const reactUtils = window['%testCafeReactSelectorUtils%'][reactVersion]; 74 | 75 | delete window['%testCafeReactVersion%']; 76 | 77 | return reactUtils.getReact(node, fn); 78 | } 79 | }).addCustomMethods({ 80 | withProps: (nodes, ...args) => { 81 | window['%testCafeReactFoundComponents%'] = window['%testCafeReactFoundComponents%'].filter(component => { 82 | return nodes.indexOf(component.node) > -1; 83 | }); 84 | 85 | function isObject(value) { 86 | return typeof value === 'object' && value !== null && !Array.isArray(value); 87 | } 88 | 89 | function isEql (value1, value2) { 90 | if (typeof value1 !== 'object' || value1 === null || typeof value2 !== 'object' || value2 === null) 91 | return value1 === value2; 92 | 93 | if (Object.keys(value1).length !== Object.keys(value2).length) 94 | return false; 95 | 96 | for (const prop in value1) { 97 | if (!value2.hasOwnProperty(prop)) return false; 98 | if (!isEql(value1[prop], value2[prop])) return false; 99 | } 100 | 101 | return true; 102 | } 103 | 104 | function isInclude (value1, value2) { 105 | if (typeof value1 !== 'object' || value1 === null || typeof value2 !== 'object' || value2 === null) 106 | return value1 === value2; 107 | 108 | for (const prop in value2) { 109 | if (!value1.hasOwnProperty(prop)) return false; 110 | if (!isInclude(value1[prop], value2[prop])) return false; 111 | } 112 | 113 | return true; 114 | } 115 | 116 | function matchProps(value1, value2, exactObjectMatch) { 117 | if(exactObjectMatch) return isEql(value1, value2); 118 | 119 | return isInclude(value1, value2); 120 | } 121 | 122 | function componentHasProps ({ props }, filterProps, exactObjectMatch) { 123 | if (!props) return false; 124 | 125 | for (const prop of Object.keys(filterProps)) { 126 | if (!props.hasOwnProperty(prop)) return false; 127 | 128 | if (!matchProps(props[prop], filterProps[prop], exactObjectMatch)) 129 | return false; 130 | } 131 | 132 | return true; 133 | } 134 | 135 | const reactVersion = window['%testCafeReactVersion%']; 136 | let filterProps = {}; 137 | let options = null; 138 | 139 | const firstArgsIsObject = isObject(args[0]); 140 | 141 | if (args.length === 2 && firstArgsIsObject) 142 | options = args[1]; 143 | 144 | else if (args.length > 2) 145 | options = args[2]; 146 | 147 | if(args.length < 2 && !firstArgsIsObject) 148 | throw new Error(`The "props" option value is expected to be a non-null object, but it is ${typeof args[0]}.`); 149 | else if(typeof args[0] !== 'string' && !firstArgsIsObject) 150 | throw new Error(`The first argument is expected to be a property name string or a "props" non-null object, but it is ${typeof args[0]}.`); 151 | 152 | if(options && typeof options !== 'object' && !Array.isArray(args[0])) 153 | throw new Error(`The "options" value is expected to be an object, but it is ${typeof options}.`); 154 | 155 | if (args.length > 1) { 156 | if(firstArgsIsObject) 157 | filterProps = args[0]; 158 | else 159 | filterProps[args[0]] = args[1]; 160 | } 161 | 162 | else if (args[0]) filterProps = args[0]; 163 | 164 | let getComponentForDOMNode = window['%testCafeReactSelectorUtils%'][reactVersion].getComponentForDOMNode; 165 | 166 | const filteredNodes = []; 167 | const exactObjectMatch = options && options.exactObjectMatch || false; 168 | 169 | const foundInstances = nodes.filter(node => { 170 | const componentInstance = getComponentForDOMNode(node); 171 | 172 | if(componentInstance && componentHasProps(componentInstance, filterProps, exactObjectMatch)) { 173 | filteredNodes.push(node); 174 | 175 | return true; 176 | } 177 | }); 178 | 179 | return foundInstances; 180 | }, 181 | 182 | withKey: (nodes, key) => { 183 | if(key === void 0 || key === null) return []; 184 | 185 | const keyString = key.toString(); 186 | 187 | window['%testCafeReactFoundComponents%'] = window['%testCafeReactFoundComponents%'].filter(component => { 188 | return nodes.indexOf(component.node) > -1; 189 | }); 190 | 191 | const reactVersion = window['%testCafeReactVersion%']; 192 | const reactUtils = window['%testCafeReactSelectorUtils%'][reactVersion]; 193 | 194 | let getComponentForDOMNode = reactUtils.getComponentForDOMNode; 195 | let getComponentKey = reactUtils.getComponentKey; 196 | 197 | const filteredNodes = []; 198 | 199 | const foundInstances = nodes.filter(node => { 200 | const componentInstance = getComponentForDOMNode(node); 201 | const componentKey = getComponentKey(componentInstance); 202 | 203 | if(componentInstance && componentKey === keyString) { 204 | filteredNodes.push(node); 205 | 206 | return true; 207 | } 208 | }); 209 | 210 | return foundInstances; 211 | }, 212 | 213 | findReact: (nodes, selector) => { 214 | const reactVersion = window['%testCafeReactVersion%']; 215 | const reactUtils = window['%testCafeReactSelectorUtils%'][reactVersion]; 216 | 217 | let componentInstances = null; 218 | let scanDOMNodeForReactComponent = reactUtils.scanDOMNodeForReactComponent; 219 | 220 | componentInstances = nodes.map(scanDOMNodeForReactComponent); 221 | 222 | const reactSelector = window['$testCafeReactSelector']; 223 | 224 | return reactSelector(selector, true, componentInstances); 225 | } 226 | }, { returnDOMNodes: true }); 227 | 228 | exports.waitForReact = {{{waitForReact}}}; 229 | -------------------------------------------------------------------------------- /src/react-15/get-root-els.js: -------------------------------------------------------------------------------- 1 | /*global document*/ 2 | 3 | /*eslint-disable no-unused-vars*/ 4 | function getRootElsReact15 () { 5 | /*eslint-enable no-unused-vars*/ 6 | 7 | const ELEMENT_NODE = 1; 8 | 9 | function getRootComponent (el) { 10 | if (!el || el.nodeType !== ELEMENT_NODE) return null; 11 | 12 | for (var prop of Object.keys(el)) { 13 | if (!/^__reactInternalInstance/.test(prop)) continue; 14 | 15 | return el[prop]._hostContainerInfo._topLevelWrapper._renderedComponent; 16 | } 17 | 18 | return null; 19 | } 20 | 21 | const rootEls = [].slice.call(document.querySelectorAll('[data-reactroot]')); 22 | const checkRootEls = rootEls.length && 23 | Object.keys(rootEls[0]).some(prop => { 24 | const rootEl = rootEls[0]; 25 | 26 | //NOTE: server rendering in React 16 also adds data-reactroot attribute, we check existing the 27 | //alternate field because it doesn't exists in React 15. 28 | return /^__reactInternalInstance/.test(prop) && !rootEl[prop].hasOwnProperty('alternate'); 29 | }); 30 | 31 | return (checkRootEls && rootEls || []).map(getRootComponent); 32 | } 33 | -------------------------------------------------------------------------------- /src/react-15/index.js: -------------------------------------------------------------------------------- 1 | /*global window rootEls defineSelectorProperty visitedRootEls checkRootNodeVisited*/ 2 | 3 | /*eslint-disable no-unused-vars*/ 4 | function react15elector (selector, _, parents = rootEls) { 5 | const ELEMENT_NODE = 1; 6 | const COMMENT_NODE = 8; 7 | 8 | window['%testCafeReactFoundComponents%'] = []; 9 | 10 | const { getName, getRootComponent } = window['%testCafeReactSelectorUtils%']['15']; 11 | 12 | function getRenderedChildren (component) { 13 | const hostNode = component.getHostNode(); 14 | const hostNodeType = hostNode.nodeType; 15 | const container = component._instance && component._instance.container; 16 | const isRootComponent = hostNode.hasAttribute && hostNode.hasAttribute('data-reactroot'); 17 | 18 | //NOTE: prevent the repeating visiting of reactRoot Component inside of portal 19 | if (component._renderedComponent && isRootComponent) { 20 | if (checkRootNodeVisited(component._renderedComponent)) 21 | return []; 22 | 23 | visitedRootEls.push(component._renderedComponent); 24 | } 25 | 26 | //NOTE: Detect if it's a portal component 27 | if (hostNodeType === COMMENT_NODE && container) { 28 | const domNode = container.querySelector('[data-reactroot]'); 29 | 30 | return { _: getRootComponent(domNode) }; 31 | } 32 | 33 | return component._renderedChildren || 34 | component._renderedComponent && 35 | { _: component._renderedComponent } || 36 | {}; 37 | } 38 | 39 | function parseSelectorElements (compositeSelector) { 40 | return compositeSelector 41 | .split(' ') 42 | .filter(el => !!el) 43 | .map(el => el.trim()); 44 | } 45 | 46 | function reactSelect (compositeSelector) { 47 | const foundComponents = []; 48 | 49 | function findDOMNode (rootEl) { 50 | if (typeof compositeSelector !== 'string') 51 | throw new Error(`Selector option is expected to be a string, but it was ${typeof compositeSelector}.`); 52 | 53 | var selectorIndex = 0; 54 | var selectorElms = parseSelectorElements(compositeSelector); 55 | 56 | if (selectorElms.length) 57 | defineSelectorProperty(selectorElms[selectorElms.length - 1]); 58 | 59 | function walk (reactComponent, cb) { 60 | if (!reactComponent) return; 61 | 62 | const componentWasFound = cb(reactComponent); 63 | 64 | //NOTE: we're looking for only between the children of component 65 | if (selectorIndex > 0 && selectorIndex < selectorElms.length && !componentWasFound) { 66 | const isTag = selectorElms[selectorIndex].toLowerCase() === selectorElms[selectorIndex]; 67 | const parent = reactComponent._hostParent; 68 | 69 | if (isTag && parent) { 70 | var renderedChildren = parent._renderedChildren; 71 | const renderedChildrenKeys = Object.keys(renderedChildren); 72 | 73 | const currentElementId = renderedChildrenKeys.filter(key => { 74 | var renderedComponent = renderedChildren[key]._renderedComponent; 75 | 76 | return renderedComponent && renderedComponent._domID === reactComponent._domID; 77 | })[0]; 78 | 79 | if (!renderedChildren[currentElementId]) 80 | return; 81 | } 82 | } 83 | 84 | const currSelectorIndex = selectorIndex; 85 | 86 | renderedChildren = getRenderedChildren(reactComponent); 87 | 88 | 89 | Object.keys(renderedChildren).forEach(key => { 90 | walk(renderedChildren[key], cb); 91 | 92 | selectorIndex = currSelectorIndex; 93 | }); 94 | } 95 | 96 | return walk(rootEl, reactComponent => { 97 | const componentName = getName(reactComponent); 98 | 99 | if (!componentName) return false; 100 | 101 | const domNode = reactComponent.getHostNode(); 102 | 103 | if (selectorElms[selectorIndex] !== componentName) return false; 104 | 105 | if (selectorIndex === selectorElms.length - 1) { 106 | if (foundComponents.indexOf(domNode) === -1) 107 | foundComponents.push(domNode); 108 | 109 | window['%testCafeReactFoundComponents%'].push({ node: domNode, component: reactComponent }); 110 | } 111 | 112 | selectorIndex++; 113 | 114 | return true; 115 | }); 116 | } 117 | 118 | [].forEach.call(parents, findDOMNode); 119 | 120 | return foundComponents; 121 | } 122 | 123 | return reactSelect(selector); 124 | } 125 | -------------------------------------------------------------------------------- /src/react-15/react-utils.js: -------------------------------------------------------------------------------- 1 | /*global window*/ 2 | (function () { 3 | const ELEMENT_NODE = 1; 4 | const COMMENT_NODE = 8; 5 | 6 | /*eslint-enable no-unused-vars*/ 7 | function getName (component) { 8 | const currentElement = component._currentElement; 9 | 10 | let name = component.getName ? component.getName() : component._tag; 11 | 12 | //NOTE: getName() returns null in IE, also it try to get function name for a stateless component 13 | if (name === null && currentElement && typeof currentElement === 'object') { 14 | const matches = currentElement.type.toString().match(/^function\s*([^\s(]+)/); 15 | 16 | if (matches) name = matches[1]; 17 | } 18 | 19 | return name; 20 | } 21 | 22 | function getRootComponent (el) { 23 | if (!el || el.nodeType !== ELEMENT_NODE) return null; 24 | 25 | for (var prop of Object.keys(el)) { 26 | if (!/^__reactInternalInstance/.test(prop)) continue; 27 | 28 | return el[prop]._hostContainerInfo._topLevelWrapper._renderedComponent; 29 | } 30 | 31 | return null; 32 | } 33 | 34 | function copyReactObject (obj) { 35 | var copiedObj = {}; 36 | 37 | for (var prop in obj) { 38 | if (obj.hasOwnProperty(prop) && prop !== 'children') 39 | copiedObj[prop] = obj[prop]; 40 | } 41 | 42 | return copiedObj; 43 | } 44 | 45 | function getComponentInstance (component) { 46 | const parent = component._hostParent || component; 47 | const renderedChildren = parent._renderedChildren || { _: component._renderedComponent } || {}; 48 | const renderedChildrenKeys = Object.keys(renderedChildren); 49 | const componentName = window['%testCafeReactSelector%']; 50 | 51 | for (let index = 0; index < renderedChildrenKeys.length; ++index) { 52 | const key = renderedChildrenKeys[index]; 53 | let renderedComponent = renderedChildren[key]; 54 | let componentInstance = null; 55 | 56 | while (renderedComponent) { 57 | if (componentName === getName(renderedComponent)) 58 | componentInstance = renderedComponent._instance || renderedComponent._currentElement; 59 | 60 | if (renderedComponent._domID === component._domID) 61 | return componentInstance; 62 | 63 | renderedComponent = renderedComponent._renderedComponent; 64 | } 65 | } 66 | 67 | return null; 68 | } 69 | 70 | function getComponentForDOMNode (el) { 71 | if (!el || !(el.nodeType === ELEMENT_NODE || el.nodeType === COMMENT_NODE)) 72 | return null; 73 | 74 | const isRootNode = el.hasAttribute && el.hasAttribute('data-reactroot'); 75 | const componentName = window['%testCafeReactSelector%']; 76 | 77 | if (isRootNode) { 78 | const rootComponent = getRootComponent(el); 79 | 80 | //NOTE: check if it's not a portal component 81 | if (getName(rootComponent) === componentName) 82 | return rootComponent._instance; 83 | 84 | return getComponentInstance(rootComponent); 85 | } 86 | 87 | for (var prop of Object.keys(el)) { 88 | if (!/^__reactInternalInstance/.test(prop)) 89 | continue; 90 | 91 | return getComponentInstance(el[prop]); 92 | } 93 | 94 | return null; 95 | } 96 | 97 | function getComponentKey (component) { 98 | const currentElement = component._reactInternalInstance ? component._reactInternalInstance._currentElement : component; 99 | 100 | return currentElement.key; 101 | } 102 | 103 | /*eslint-disable no-unused-vars*/ 104 | function getReact (node, fn) { 105 | /*eslint-enable no-unused-vars*/ 106 | const componentInstance = getComponentForDOMNode(node); 107 | 108 | if (!componentInstance) return null; 109 | 110 | delete window['%testCafeReactSelector%']; 111 | 112 | if (typeof fn === 'function') { 113 | return fn({ 114 | state: copyReactObject(componentInstance.state), 115 | props: copyReactObject(componentInstance.props), 116 | key: getComponentKey(componentInstance) 117 | }); 118 | } 119 | 120 | return { 121 | state: copyReactObject(componentInstance.state), 122 | props: copyReactObject(componentInstance.props), 123 | key: getComponentKey(componentInstance) 124 | }; 125 | } 126 | 127 | function getFoundComponentInstances () { 128 | return window['%testCafeReactFoundComponents%'].map(desc => desc.component); 129 | } 130 | 131 | function scanDOMNodeForReactComponent (el) { 132 | if (!el || !(el.nodeType === ELEMENT_NODE || el.nodeType === COMMENT_NODE)) 133 | return null; 134 | 135 | let component = null; 136 | 137 | for (const prop of Object.keys(el)) { 138 | if (!/^__reactInternalInstance/.test(prop)) 139 | continue; 140 | 141 | component = el[prop]; 142 | 143 | break; 144 | } 145 | 146 | if (!component) return null; 147 | 148 | const parent = component._hostParent; 149 | 150 | if (!parent) return component; 151 | 152 | const renderedChildren = parent._renderedChildren; 153 | const renderedChildrenKeys = Object.keys(renderedChildren); 154 | 155 | const currentElementId = renderedChildrenKeys.filter(key => { 156 | const renderedComponent = renderedChildren[key]; 157 | 158 | return renderedComponent && renderedComponent.getHostNode() === el; 159 | })[0]; 160 | 161 | return renderedChildren[currentElementId]; 162 | } 163 | 164 | return { 165 | getReact, 166 | getComponentForDOMNode, 167 | scanDOMNodeForReactComponent, 168 | getFoundComponentInstances, 169 | getComponentKey, 170 | getName, 171 | getRootComponent 172 | }; 173 | })(); 174 | 175 | -------------------------------------------------------------------------------- /src/react-16-18/get-root-els.js: -------------------------------------------------------------------------------- 1 | /*global document*/ 2 | 3 | /*eslint-disable no-unused-vars*/ 4 | function getRootElsReact16to18 (el) { 5 | el = el || document.body; 6 | 7 | let rootEls = []; 8 | 9 | if (el._reactRootContainer) { 10 | const rootContainer = el._reactRootContainer._internalRoot || el._reactRootContainer; 11 | 12 | rootEls.push(rootContainer.current.child); 13 | } 14 | 15 | else { 16 | //NOTE: approach for React 18 createRoot API 17 | for (var prop of Object.keys(el)) { 18 | if (!/^__reactContainer/.test(prop)) continue; 19 | 20 | //NOTE: component and its alternate version has the same stateNode, but stateNode has the link to rendered version in the 'current' field 21 | const component = el[prop].stateNode.current; 22 | 23 | rootEls.push(component); 24 | 25 | break; 26 | } 27 | } 28 | 29 | const children = el.children; 30 | 31 | for (let index = 0; index < children.length; ++index) { 32 | const child = children[index]; 33 | 34 | rootEls = rootEls.concat(getRootElsReact16to18(child)); 35 | 36 | } 37 | 38 | return rootEls; 39 | } 40 | -------------------------------------------------------------------------------- /src/react-16-18/index.js: -------------------------------------------------------------------------------- 1 | /*global window document Node rootEls defineSelectorProperty visitedRootEls checkRootNodeVisited*/ 2 | 3 | /*eslint-disable no-unused-vars*/ 4 | function react16to18Selector (selector, renderedRootIsUnknown, parents = rootEls) { 5 | window['%testCafeReactFoundComponents%'] = []; 6 | 7 | const { getRenderedComponentVersion } = window['%testCafeReactSelectorUtils%']['16|17|18']; 8 | 9 | /*eslint-enable no-unused-vars*/ 10 | function createAnnotationForEmptyComponent (component) { 11 | const comment = document.createComment('testcafe-react-selectors: the requested component didn\'t render any DOM elements'); 12 | 13 | comment.__$$reactInstance = component; 14 | 15 | if (!window['%testCafeReactEmptyComponent%']) window['%testCafeReactEmptyComponent%'] = []; 16 | 17 | window['%testCafeReactEmptyComponent%'].push(comment); 18 | 19 | return comment; 20 | } 21 | 22 | function getName (component) { 23 | //react memo 24 | // it will find the displayName on the elementType if you set it 25 | if (component.elementType && component.elementType.displayName) return component.elementType.displayName; 26 | 27 | 28 | if (!component.type && !component.memoizedState) 29 | return null; 30 | 31 | const currentElement = component.type ? component : component.memoizedState.element; 32 | 33 | //NOTE: tag 34 | if (component.type) { 35 | if (typeof component.type === 'string') return component.type; 36 | if (component.type.displayName || component.type.name) return component.type.displayName || component.type.name; 37 | } 38 | 39 | const matches = currentElement?.type.toString().match(/^function\s*([^\s(]+)/); 40 | 41 | if (matches) return matches[1]; 42 | 43 | return null; 44 | } 45 | 46 | function findNodeWithStateNodeInChildrenOrSiblings (searchNode) { 47 | const nodesToCheck = []; 48 | 49 | nodesToCheck.push(searchNode); 50 | while (nodesToCheck.length > 0) { 51 | const node = nodesToCheck.shift(); 52 | 53 | if (node.stateNode instanceof Node) return node; 54 | if (node.child) nodesToCheck.push(node.child); 55 | if (node.sibling && node !== searchNode) nodesToCheck.push(node.sibling); 56 | } 57 | return searchNode; 58 | } 59 | 60 | function getContainer (component) { 61 | let node = renderedRootIsUnknown ? getRenderedComponentVersion(component) : component; 62 | 63 | node = findNodeWithStateNodeInChildrenOrSiblings(node); 64 | 65 | if (!(node.stateNode instanceof Node)) 66 | return null; 67 | 68 | return node.stateNode; 69 | } 70 | 71 | function getRenderedChildren (component) { 72 | const isRootComponent = rootEls.indexOf(component) > -1; 73 | 74 | //Nested root element 75 | if (isRootComponent) { 76 | if (checkRootNodeVisited(component)) return []; 77 | 78 | visitedRootEls.push(component); 79 | } 80 | 81 | //Portal component 82 | if (!component.child) { 83 | const portalRoot = component.stateNode && component.stateNode.container && 84 | component.stateNode.container._reactRootContainer; 85 | 86 | const rootContainer = portalRoot && (portalRoot._internalRoot || portalRoot); 87 | 88 | if (rootContainer) component = rootContainer.current; 89 | } 90 | 91 | if (!component.child) return []; 92 | 93 | let currentChild = component.child; 94 | 95 | if (typeof component.type !== 'string') 96 | currentChild = component.child; 97 | 98 | const children = [currentChild]; 99 | 100 | while (currentChild.sibling) { 101 | children.push(currentChild.sibling); 102 | 103 | currentChild = currentChild.sibling; 104 | } 105 | 106 | return children; 107 | } 108 | 109 | function parseSelectorElements (compositeSelector) { 110 | return compositeSelector 111 | .split(' ') 112 | .filter(el => !!el) 113 | .map(el => el.trim()); 114 | } 115 | 116 | function reactSelect (compositeSelector) { 117 | const foundComponents = []; 118 | 119 | function findDOMNode (rootComponent) { 120 | if (typeof compositeSelector !== 'string') 121 | throw new Error(`Selector option is expected to be a string, but it was ${typeof compositeSelector}.`); 122 | 123 | var selectorIndex = 0; 124 | var selectorElms = parseSelectorElements(compositeSelector); 125 | 126 | if (selectorElms.length) 127 | defineSelectorProperty(selectorElms[selectorElms.length - 1]); 128 | 129 | function walk (reactComponent, cb) { 130 | if (!reactComponent) return; 131 | 132 | const componentWasFound = cb(reactComponent); 133 | const currSelectorIndex = selectorIndex; 134 | 135 | const isNotFirstSelectorPart = selectorIndex > 0 && selectorIndex < selectorElms.length; 136 | 137 | if (isNotFirstSelectorPart && !componentWasFound) { 138 | const isTag = selectorElms[selectorIndex].toLowerCase() === selectorElms[selectorIndex]; 139 | 140 | //NOTE: we're looking for only between the children of component 141 | if (isTag && getName(reactComponent.return) !== selectorElms[selectorIndex - 1]) 142 | return; 143 | } 144 | 145 | const renderedChildren = getRenderedChildren(reactComponent); 146 | 147 | Object.keys(renderedChildren).forEach(key => { 148 | walk(renderedChildren[key], cb); 149 | 150 | selectorIndex = currSelectorIndex; 151 | }); 152 | } 153 | 154 | return walk(rootComponent, reactComponent => { 155 | const componentName = getName(reactComponent); 156 | 157 | if (!componentName) return false; 158 | 159 | const domNode = getContainer(reactComponent); 160 | 161 | if (selectorElms[selectorIndex] !== componentName) return false; 162 | 163 | if (selectorIndex === selectorElms.length - 1) { 164 | if (foundComponents.indexOf(domNode) === -1) 165 | foundComponents.push(domNode || createAnnotationForEmptyComponent(reactComponent)); 166 | 167 | window['%testCafeReactFoundComponents%'].push({ node: domNode, component: reactComponent }); 168 | } 169 | 170 | selectorIndex++; 171 | 172 | return true; 173 | }); 174 | } 175 | 176 | [].forEach.call(parents, findDOMNode); 177 | 178 | return foundComponents; 179 | } 180 | 181 | return reactSelect(selector); 182 | } 183 | -------------------------------------------------------------------------------- /src/react-16-18/react-utils.js: -------------------------------------------------------------------------------- 1 | /*global window Node*/ 2 | (function () { 3 | const ELEMENT_NODE = 1; 4 | const COMMENT_NODE = 8; 5 | 6 | //https://github.com/facebook/react/commit/2ba43edc2675380a0f2222f351475bf9d750c6a9 7 | //__reactInternalInstance - react 16 8 | //__reactFiber - react 17 9 | const REACT_INTERNAL_INSTANCE_PROP_RE = /^__reactInternalInstance|^__reactFiber/; 10 | 11 | function copyReactObject (obj) { 12 | var copiedObj = {}; 13 | 14 | for (var prop in obj) { 15 | if (obj.hasOwnProperty(prop) && prop !== 'children') 16 | copiedObj[prop] = obj[prop]; 17 | } 18 | 19 | return copiedObj; 20 | } 21 | 22 | function getComponentForDOMNode (el) { 23 | if (!el || !(el.nodeType === ELEMENT_NODE || el.nodeType === COMMENT_NODE)) return null; 24 | 25 | let component = null; 26 | const emptyComponentFound = window['%testCafeReactEmptyComponent%'] && 27 | window['%testCafeReactEmptyComponent%'].length; 28 | 29 | if (emptyComponentFound && el.nodeType === COMMENT_NODE) 30 | component = window['%testCafeReactEmptyComponent%'].shift().__$$reactInstance; 31 | 32 | else if (window['%testCafeReactFoundComponents%'].length) 33 | component = window['%testCafeReactFoundComponents%'].filter(desc => desc.node === el)[0].component; 34 | 35 | const props = component.stateNode && component.stateNode.props || component.memoizedProps; 36 | const state = component.stateNode && component.stateNode.state || component.memoizedState; 37 | const key = component.key; 38 | 39 | return { props, state, key }; 40 | } 41 | 42 | /*eslint-enable no-unused-vars*/ 43 | function getReact (node, fn) { 44 | /*eslint-disable no-unused-vars*/ 45 | const componentInstance = getComponentForDOMNode(node); 46 | 47 | if (!componentInstance) return null; 48 | 49 | delete window['%testCafeReactSelector%']; 50 | delete window['%testCafeReactEmptyComponent%']; 51 | delete window['%testCafeReactFoundComponents%']; 52 | 53 | if (typeof fn === 'function') { 54 | return fn({ 55 | state: copyReactObject(componentInstance.state), 56 | props: copyReactObject(componentInstance.props), 57 | key: componentInstance.key 58 | }); 59 | } 60 | 61 | return { 62 | state: copyReactObject(componentInstance.state), 63 | props: copyReactObject(componentInstance.props), 64 | key: componentInstance.key 65 | }; 66 | } 67 | 68 | function scanDOMNodeForReactInstance (el) { 69 | if (!el || !(el.nodeType === ELEMENT_NODE || el.nodeType === COMMENT_NODE)) return null; 70 | 71 | if (el.nodeType === COMMENT_NODE) 72 | return el.__$$reactInstance.return.child; 73 | 74 | for (var prop of Object.keys(el)) { 75 | if (!REACT_INTERNAL_INSTANCE_PROP_RE.test(prop)) continue; 76 | 77 | let nestedComponent = el[prop]; 78 | 79 | if (typeof nestedComponent.type !== 'string') 80 | return nestedComponent; 81 | 82 | let parentComponent = nestedComponent; 83 | 84 | do { 85 | nestedComponent = parentComponent; 86 | parentComponent = nestedComponent.return; 87 | } while (parentComponent && parentComponent.type && !(parentComponent.stateNode instanceof Node)); 88 | 89 | return nestedComponent; 90 | } 91 | 92 | return null; 93 | } 94 | 95 | function getRenderedComponentVersion (component) { 96 | const rootContainers = window['$testCafeReact16to18RootContainers']; 97 | 98 | if (!component.alternate) return component; 99 | 100 | let component1 = component; 101 | let component2 = component.alternate; 102 | 103 | while (component1.return) component1 = component1.return; 104 | while (component2.return) component2 = component2.return; 105 | 106 | if (rootContainers.indexOf(component1) > -1) return component; 107 | 108 | return component.alternate; 109 | } 110 | 111 | function scanDOMNodeForReactComponent (domNode) { 112 | const rootInstances = window['$testCafeReact16to18Roots'].map(rootEl => rootEl.return || rootEl); 113 | const reactInstance = scanDOMNodeForReactInstance(domNode); 114 | 115 | return getRenderedComponentVersion(reactInstance); 116 | } 117 | 118 | function getFoundComponentInstances () { 119 | return window['%testCafeReactFoundComponents%'].map(desc => desc.component); 120 | } 121 | 122 | function getComponentKey (instance) { 123 | return instance.key; 124 | } 125 | 126 | return { getReact, getComponentForDOMNode, scanDOMNodeForReactComponent, getFoundComponentInstances, getComponentKey, getRenderedComponentVersion }; 127 | })(); 128 | -------------------------------------------------------------------------------- /src/wait-for-react.js: -------------------------------------------------------------------------------- 1 | /*global ClientFunction document NodeFilter*/ 2 | 3 | /*eslint-disable no-unused-vars*/ 4 | function waitForReact (timeout, testController) { 5 | /*eslint-enable no-unused-vars*/ 6 | const DEFAULT_TIMEOUT = 1e4; 7 | const checkTimeout = typeof timeout === 'number' ? timeout : DEFAULT_TIMEOUT; 8 | 9 | return ClientFunction(() => { 10 | const CHECK_INTERVAL = 200; 11 | let stopChecking = false; 12 | 13 | function findReact16to18Root () { 14 | const treeWalker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT, null, false); 15 | 16 | while (treeWalker.nextNode()) { 17 | //NOTE: fast check for 16 and 17 react 18 | if (treeWalker.currentNode.hasOwnProperty('_reactRootContainer')) return true; 19 | 20 | //NOTE: react 18 21 | for (const prop of Object.keys(treeWalker.currentNode)) 22 | if (/^__reactContainer/.test(prop)) return true; 23 | } 24 | 25 | return false; 26 | } 27 | 28 | function findReact15OrStaticRenderedRoot () { 29 | const rootEl = document.querySelector('[data-reactroot]'); 30 | 31 | //NOTE: we have data-reactroot in static render even before hydration 32 | return rootEl && Object.keys(rootEl).some(prop => /^__reactInternalInstance/.test(prop)); 33 | } 34 | 35 | function findReactApp () { 36 | const isReact15OrStaticRender = findReact15OrStaticRenderedRoot(); 37 | const isReact16to18WithHandlers = !!Object.keys(document).filter(prop => /^_reactListenersID|^_reactEvents/.test(prop)).length; 38 | 39 | return isReact15OrStaticRender || isReact16to18WithHandlers || findReact16to18Root(); 40 | } 41 | 42 | return new Promise((resolve, reject) => { 43 | function tryFindReactApp () { 44 | const startTime = new Date(); 45 | const reactTreeIsFound = findReactApp(); 46 | const checkTime = new Date() - startTime; 47 | 48 | if (reactTreeIsFound) { 49 | resolve(); 50 | return; 51 | } 52 | 53 | if (stopChecking) return; 54 | 55 | setTimeout(tryFindReactApp, checkTime > CHECK_INTERVAL ? checkTime : CHECK_INTERVAL); 56 | } 57 | 58 | tryFindReactApp(); 59 | 60 | setTimeout(() => { 61 | stopChecking = true; 62 | 63 | reject('waitForReact: The waiting timeout is exceeded'); 64 | }, checkTimeout); 65 | }); 66 | }, { dependencies: { checkTimeout }, boundTestRun: testController })(); 67 | } 68 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-unused-expressions": 0 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/data/app/index-react-16.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test App 7 | 8 | 9 |
10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/data/app/index-react-17.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test App 7 | 8 | 9 |
10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/data/app/index-react-18.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test App 7 | 8 | 9 |
10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/data/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test App 7 | 8 | 9 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/data/app/page-without-react.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /test/data/app/root-pure-component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test App 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/data/app/src/AsyncComponent.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function AsyncChild() { 4 | return ( 5 |
6 | async child 7 |
8 | ) 9 | } 10 | 11 | let cacheValue = false; 12 | 13 | /** 14 | * Fake asynchronous call / in-memory cache for suspense testing purposes. 15 | */ 16 | const fakeLoader = () => { 17 | if (!cacheValue) { 18 | cacheValue = ( 19 | new Promise((resolve) => { 20 | setTimeout(() => { 21 | resolve(true) 22 | }, 1500); 23 | }) 24 | ); 25 | } 26 | 27 | return cacheValue; 28 | }; 29 | 30 | export default function AsyncComponent() { 31 | const value = use(fakeLoader()) 32 | 33 | return ( 34 |
35 | async parent 36 | {value && } 37 |
38 | ); 39 | } 40 | 41 | // use example for throwing promises to simulate async fetching 42 | // Source - Facebook's codesandbox on React Suspense Docs: 43 | // https://react.dev/reference/react/Suspense 44 | // @see https://codesandbox.io/s/s9zlw3 45 | function use(promise) { 46 | if (promise.status === 'fulfilled') { 47 | return promise.value; 48 | } else if (promise.status === 'rejected') { 49 | throw promise.reason; 50 | } else if (promise.status === 'pending') { 51 | throw promise; 52 | } else { 53 | promise.status = 'pending'; 54 | promise.then( 55 | result => { 56 | promise.status = 'fulfilled'; 57 | promise.value = result; 58 | }, 59 | reason => { 60 | promise.status = 'rejected'; 61 | promise.reason = reason; 62 | }, 63 | ); 64 | throw promise; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test/data/app/src/app-old-versions.jsx: -------------------------------------------------------------------------------- 1 | 2 | export default function AppFactory(React, ReactDOM) { 3 | class ListItem extends React.Component { 4 | constructor (props) { 5 | super(); 6 | 7 | this.state = { 8 | itemId: props.id 9 | } 10 | } 11 | 12 | render () { 13 | return
  • {this.props.id} text

  • ; 14 | } 15 | } 16 | 17 | class List extends React.Component { 18 | constructor () { 19 | super(); 20 | 21 | this.state = { isActive: false }; 22 | 23 | this._onClick = this._onClick.bind(this); 24 | } 25 | 26 | _onClick () { 27 | this.setState({ isActive: true }); 28 | } 29 | 30 | render () { 31 | return ( 32 |
    33 | List: {this.props.id} 34 |
      35 | 36 | 37 | 38 |
    39 |
    40 | ); 41 | } 42 | } 43 | 44 | class TextLabel extends React.Component { 45 | constructor () { 46 | super(); 47 | 48 | this.state = { 49 | text: 'Component inside of wrapper component' 50 | } 51 | } 52 | 53 | render () { 54 | return
    {this.state.text}
    ; 55 | } 56 | } 57 | 58 | class WrapperComponent extends React.Component { 59 | constructor () { 60 | super(); 61 | 62 | this.state = { width: 100 }; 63 | } 64 | 65 | render () { 66 | return ; 67 | } 68 | } 69 | 70 | class EmptyComponent extends React.Component { 71 | constructor () { 72 | super(); 73 | 74 | this.state = { 75 | id: 1, 76 | text: null 77 | }; 78 | } 79 | 80 | render () { 81 | return this.state.text; 82 | } 83 | } 84 | 85 | class Portal extends React.Component { 86 | constructor () { 87 | super(); 88 | 89 | this.container = document.createElement('div'); 90 | this.state = { width: 100 }; 91 | 92 | document.body.appendChild(this.container); 93 | } 94 | 95 | _renderPortal () { 96 | ReactDOM.unstable_renderSubtreeIntoContainer( 97 | this, 98 | , 99 | this.container 100 | ); 101 | } 102 | 103 | componentDidMount () { 104 | this._renderPortal(); 105 | } 106 | 107 | render () { 108 | return null; 109 | } 110 | } 111 | 112 | class PortalReact16 extends React.Component { 113 | constructor () { 114 | super(); 115 | 116 | this.container = document.createElement('div'); 117 | this.state = { width: 100 }; 118 | } 119 | 120 | componentDidMount () { 121 | document.body.appendChild(this.container); 122 | } 123 | 124 | render () { 125 | if (!ReactDOM.createPortal) return null; 126 | 127 | return ReactDOM.createPortal( 128 | , 129 | this.container 130 | ); 131 | } 132 | } 133 | 134 | class PureComponent extends React.PureComponent { 135 | constructor () { 136 | super(); 137 | } 138 | 139 | render () { 140 | return PureComponent; 141 | } 142 | } 143 | 144 | class PortalWithPureComponent extends React.Component { 145 | constructor () { 146 | super(); 147 | 148 | this.container = document.createElement('div'); 149 | 150 | document.body.appendChild(this.container); 151 | } 152 | 153 | _renderPortal () { 154 | ReactDOM.unstable_renderSubtreeIntoContainer( 155 | this, 156 | , 157 | this.container 158 | ); 159 | } 160 | 161 | componentDidMount () { 162 | this._renderPortal(); 163 | } 164 | 165 | render () { 166 | return null; 167 | } 168 | } 169 | 170 | const Stateless_1 = function Stateless1 (props) { 171 | return
    {props.text}
    ; 172 | }; 173 | 174 | const Stateless_2 = function Stateless2 () { 175 | return
    test
    ; 176 | }; 177 | 178 | const Stateless3 = function (props) { 179 | return
    {props.text}
    ; 180 | }; 181 | 182 | const Stateless_4 = function Stateless4 () { 183 | return null; 184 | }; 185 | 186 | const SiblingWithStateNode = function (props) { 187 | return <><>
    {props.text}
    ; 188 | }; 189 | 190 | class SmartComponent extends React.Component { 191 | constructor () { 192 | super(); 193 | 194 | this.state = { 195 | text: 'Disabled' 196 | }; 197 | 198 | this._onClick = this._onClick.bind(this); 199 | } 200 | 201 | _onClick () { 202 | const newText = this.state.text === 'Enabled' ? 'Disabled' : 'Enabled'; 203 | 204 | this.setState({ text: newText }); 205 | } 206 | 207 | render () { 208 | return
    ; 209 | } 210 | } 211 | 212 | const SetItemLabel = ({ text }) => {text} ; 213 | const SetItem = ({ text }) => text ? : null; 214 | 215 | class UnfilteredSet extends React.Component { 216 | render () { 217 | return (
    218 | 219 | 220 | 221 | 222 | 223 |
    ); 224 | } 225 | } 226 | 227 | class UnfilteredSet_PartialMatching extends React.Component { 228 | render () { 229 | const prop1_1 = { 230 | obj: { 231 | field1: 1, 232 | field2: 2, 233 | field3: { 234 | subField1: 1, 235 | subField2: 2 236 | } 237 | } 238 | }; 239 | 240 | const prop1_2 = { 241 | obj: { 242 | field1: 1, 243 | field2: 0, 244 | field3: { 245 | subField1: 1, 246 | subField2: 0 247 | } 248 | } 249 | }; 250 | 251 | return (
    252 | 253 | 254 | 255 |
    ); 256 | } 257 | } 258 | 259 | function NestedApp () { 260 | return ; 261 | }; 262 | 263 | const ToMemoize = ({ text }) =>
    {text}
    ; 264 | const Memoized = React.memo ? React.memo(ToMemoize) : ToMemoize; 265 | 266 | Memoized.displayName = 'Memoized'; 267 | 268 | class App extends React.Component { 269 | constructor () { 270 | super(); 271 | 272 | this.state = { 273 | isRootComponent: true 274 | }; 275 | } 276 | 277 | render () { 278 | return ( 279 |
    280 |
    281 |
    282 | 283 |
    284 |
    285 |
    286 |
    287 | 288 |
    289 |
    290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 |
    310 |
    311 | ); 312 | } 313 | } 314 | 315 | return { App, NestedApp }; 316 | }; 317 | -------------------------------------------------------------------------------- /test/data/app/src/app.jsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | 3 | const AsyncComponent = React.lazy(() => import('./AsyncComponent')); 4 | 5 | /*global React ReactDOM*/ 6 | 7 | class ListItem extends React.Component { 8 | constructor (props) { 9 | super(); 10 | 11 | this.state = { 12 | itemId: props.id 13 | } 14 | } 15 | 16 | render () { 17 | return
  • {this.props.id} text

  • ; 18 | } 19 | } 20 | 21 | class List extends React.Component { 22 | constructor () { 23 | super(); 24 | 25 | this.state = { isActive: false }; 26 | 27 | this._onClick = this._onClick.bind(this); 28 | } 29 | 30 | _onClick () { 31 | this.setState({ isActive: true }); 32 | } 33 | 34 | render () { 35 | return ( 36 |
    37 | List: {this.props.id} 38 |
      39 | 40 | 41 | 42 |
    43 |
    44 | ); 45 | } 46 | } 47 | 48 | class TextLabel extends React.Component { 49 | constructor () { 50 | super(); 51 | 52 | this.state = { 53 | text: 'Component inside of wrapper component' 54 | } 55 | } 56 | 57 | render () { 58 | return
    {this.state.text}
    ; 59 | } 60 | } 61 | 62 | class WrapperComponent extends React.Component { 63 | constructor () { 64 | super(); 65 | 66 | this.state = { width: 100 }; 67 | } 68 | 69 | render () { 70 | return ; 71 | } 72 | } 73 | 74 | class EmptyComponent extends React.Component { 75 | constructor () { 76 | super(); 77 | 78 | this.state = { 79 | id: 1, 80 | text: null 81 | }; 82 | } 83 | 84 | render () { 85 | return this.state.text; 86 | } 87 | } 88 | 89 | class PureComponent extends React.PureComponent { 90 | constructor () { 91 | super(); 92 | } 93 | 94 | render () { 95 | return PureComponent; 96 | } 97 | } 98 | 99 | const Stateless_1 = function Stateless1 (props) { 100 | return
    {props.text}
    ; 101 | }; 102 | 103 | const Stateless_2 = function Stateless2 () { 104 | return
    test
    ; 105 | }; 106 | 107 | const Stateless3 = function (props) { 108 | return
    {props.text}
    ; 109 | }; 110 | 111 | const Stateless_4 = function Stateless4 () { 112 | return null; 113 | }; 114 | 115 | const SiblingWithStateNode = function (props) { 116 | return <><>
    {props.text}
    ; 117 | }; 118 | 119 | class SmartComponent extends React.Component { 120 | constructor () { 121 | super(); 122 | 123 | this.state = { 124 | text: 'Disabled' 125 | }; 126 | 127 | this._onClick = this._onClick.bind(this); 128 | } 129 | 130 | _onClick () { 131 | const newText = this.state.text === 'Enabled' ? 'Disabled' : 'Enabled'; 132 | 133 | this.setState({ text: newText }); 134 | } 135 | 136 | render () { 137 | return
    ; 138 | } 139 | } 140 | 141 | const SetItemLabel = ({ text }) => {text} ; 142 | const SetItem = ({ text }) => text ? : null; 143 | 144 | class UnfilteredSet extends React.Component { 145 | render () { 146 | return (
    147 | 148 | 149 | 150 | 151 | 152 |
    ); 153 | } 154 | } 155 | 156 | class UnfilteredSet_PartialMatching extends React.Component { 157 | render () { 158 | const prop1_1 = { 159 | obj: { 160 | field1: 1, 161 | field2: 2, 162 | field3: { 163 | subField1: 1, 164 | subField2: 2 165 | } 166 | } 167 | }; 168 | 169 | const prop1_2 = { 170 | obj: { 171 | field1: 1, 172 | field2: 0, 173 | field3: { 174 | subField1: 1, 175 | subField2: 0 176 | } 177 | } 178 | }; 179 | 180 | return (
    181 | 182 | 183 | 184 |
    ); 185 | } 186 | } 187 | 188 | export function NestedApp () { 189 | return ; 190 | }; 191 | 192 | const ToMemoize = ({ text }) =>
    {text}
    ; 193 | const Memoized = React.memo ? React.memo(ToMemoize) : ToMemoize; 194 | 195 | Memoized.displayName = 'Memoized'; 196 | 197 | export class App extends React.Component { 198 | constructor () { 199 | super(); 200 | 201 | this.state = { 202 | isRootComponent: true 203 | }; 204 | } 205 | 206 | render () { 207 | return ( 208 |
    209 |
    210 |
    211 | 212 |
    213 |
    214 |
    215 |
    216 | 217 |
    218 |
    219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 |
    234 | loading}> 235 | 236 | 237 |
    238 | ); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /test/data/app/src/main-react-16.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react16'; 2 | import ReactDOM from 'react-dom16'; 3 | import AppFactory from './app-old-versions'; 4 | 5 | const { App, NestedApp } = AppFactory(React, ReactDOM); 6 | 7 | ReactDOM.render(React.createElement(App, { label: 'AppLabel' }), document.getElementById('app-container')); 8 | ReactDOM.render(React.createElement(NestedApp, { label: 'NestedAppLabel' }), document.getElementById('nestedapp-container')); -------------------------------------------------------------------------------- /test/data/app/src/main-react-17.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react17'; 2 | import ReactDOM from 'react-dom17'; 3 | import AppFactory from './app-old-versions'; 4 | 5 | const { App, NestedApp } = AppFactory(React, ReactDOM); 6 | 7 | ReactDOM.render(React.createElement(App, { label: 'AppLabel' }), document.getElementById('app-container')); 8 | ReactDOM.render(React.createElement(NestedApp, { label: 'NestedAppLabel' }), document.getElementById('nestedapp-container')); -------------------------------------------------------------------------------- /test/data/app/src/main-react-18.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { App, NestedApp } from './app'; 4 | 5 | ReactDOM.createRoot(document.getElementById('app-container')).render( 6 | 7 | 8 | 9 | ); 10 | 11 | //NOTE: react 18 does not have a render callback 12 | setTimeout(() => { 13 | ReactDOM.createRoot(document.getElementById('nestedapp-container')).render( 14 | 15 | ); 16 | }, 0); 17 | -------------------------------------------------------------------------------- /test/data/app/src/root-pure-component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | class PureComponent extends React.PureComponent { 5 | constructor () { 6 | super(); 7 | } 8 | 9 | render () { 10 | return ( 11 |
    12 | {this.props.children} 13 |
    14 | ) 15 | } 16 | } 17 | 18 | class App extends React.PureComponent { 19 | constructor () { 20 | super(); 21 | 22 | this.state = { 23 | text: 'PureComponent' 24 | } 25 | } 26 | 27 | render () { 28 | return {this.state.text}; 29 | } 30 | } 31 | 32 | ReactDOM.createRoot(document.getElementById('app-container')).render( 33 | 34 | ); -------------------------------------------------------------------------------- /test/data/app/src/stateless-root.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | class PureComponent extends React.PureComponent { 5 | constructor () { 6 | super(); 7 | 8 | this.state = { 9 | text: 'PureComponent' 10 | } 11 | } 12 | 13 | render () { 14 | return {this.state.text}; 15 | } 16 | } 17 | 18 | const App = () => { 19 | return ( 20 |
    21 | 22 |
    23 | ); 24 | }; 25 | 26 | ReactDOM.createRoot(document.getElementById('app-container')).render( 27 | 28 | ); -------------------------------------------------------------------------------- /test/data/app/stateless-root.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test App 5 | 6 | 7 |
    8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/data/server-render/pages/index.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | class Label extends React.Component { 4 | constructor () { 5 | super(); 6 | 7 | this.state = { 8 | text: 'Label Text...' 9 | }; 10 | } 11 | 12 | render () { 13 | return {this.state.text}; 14 | } 15 | } 16 | 17 | const App = () =>
    ; 18 | 19 | export default App; 20 | 21 | export async function getServerSideProps () { 22 | return { props: { } }; 23 | } -------------------------------------------------------------------------------- /test/fixtures/common-tests.js: -------------------------------------------------------------------------------- 1 | /*global fixture test document*/ 2 | import fs from 'fs'; 3 | import { ReactSelector, waitForReact } from '../../'; 4 | import { ClientFunction } from 'testcafe'; 5 | 6 | const SUPPORTED_VERSIONS = [16, 17, 18]; 7 | 8 | //NOTE: React 18 does not support unstable_renderSubtreeIntoContainer anymore. 9 | const listMap = { 10 | '16': 4, 11 | '17': 4, 12 | '18': 2 13 | }; 14 | 15 | const itemsInList = 3; 16 | 17 | /*eslint-disable no-loop-func*/ 18 | for (const version of SUPPORTED_VERSIONS) { 19 | fixture `ReactJS TestCafe plugin (React ${version})` 20 | .page`http://localhost:3000/index-react-${version}.html`; 21 | 22 | test('Should throw exception for non-valid selectors', async t => { 23 | for (const selector of [null, false, void 0, {}, 42]) { 24 | try { 25 | await ReactSelector(selector); 26 | } 27 | catch (e) { 28 | await t.expect(e.errMsg).contains(`Selector option is expected to be a string, but it was ${typeof selector}.`); 29 | } 30 | } 31 | }); 32 | 33 | test('Should get DOM node by react selector', async t => { 34 | const app = ReactSelector('App'); 35 | const list = ReactSelector('List'); 36 | const listItem1 = ReactSelector('ListItem').nth(0); 37 | const listItem2 = ReactSelector('ListItem').nth(1); 38 | 39 | await t 40 | .expect(list.count).eql(listMap[version]) 41 | .expect(app.id).eql('app') 42 | .expect(listItem1.id).eql('l1-item1') 43 | .expect(listItem2.id).eql('l1-item2'); 44 | }); 45 | 46 | test('Should get DOM node by composite selector', async t => { 47 | const listItem1 = ReactSelector('List ListItem'); 48 | const listItem2 = ReactSelector('List ListItem').nth(1); 49 | 50 | await t 51 | .expect(listItem1.id).eql('l1-item1') 52 | .expect(listItem2.id).eql('l1-item2'); 53 | }); 54 | 55 | test('Should get DOM node for stateless component', async t => { 56 | await t 57 | .expect(ReactSelector('Stateless1').textContent).ok('test') 58 | .expect(ReactSelector('Stateless2').exists).ok() 59 | .expect(ReactSelector('Stateless3').exists).ok() 60 | //Stateless component with empty render GH-62 61 | .expect(ReactSelector('Stateless4').exists).ok(); 62 | }); 63 | 64 | test('Should get DOM node for component where first child does not have a stateNode', async t => { 65 | await t 66 | .expect(ReactSelector('SiblingWithStateNode').textContent).eql('SiblingWithStateNodeText'); 67 | }); 68 | 69 | test('Should get DOM node for pure component', async t => { 70 | await t.expect(ReactSelector('PureComponent').exists).ok(); 71 | }); 72 | 73 | test('Should not get DOM node for element outside react component tree', async t => { 74 | await t.expect(await ReactSelector.with({ timeout: 100 })('figure')).notOk(); 75 | }); 76 | 77 | test('Should get component state', async t => { 78 | const appReact = await ReactSelector('App').getReact(); 79 | const listItem1React = await ReactSelector('ListItem').getReact(); 80 | const listItem2React = await ReactSelector('ListItem').nth(1).getReact(); 81 | const listItem3 = await ReactSelector('ListItem').nth(2); 82 | const listItem3ItemId = listItem3.getReact(({ state }) => state.itemId); 83 | 84 | const tagReact = await ReactSelector('ListItem p').getReact(); 85 | 86 | await t 87 | .expect(appReact.state).eql({ isRootComponent: true }) 88 | 89 | .expect(listItem1React.state).eql({ itemId: 'l1-item1' }) 90 | .expect(listItem2React.state).eql({ itemId: 'l1-item2' }) 91 | 92 | .expect(listItem3ItemId).eql('l1-item3') 93 | 94 | .expect(tagReact).eql({ state: {}, props: {}, key: 'l1-item1-p' }); 95 | }); 96 | 97 | test('Should get component props', async t => { 98 | const appReact = await ReactSelector('App').getReact(); 99 | const listItem1React = await ReactSelector('ListItem').getReact(); 100 | const listItem2React = await ReactSelector('ListItem').nth(1).getReact(); 101 | const listItem3 = await ReactSelector('ListItem').nth(2); 102 | const listItem3Id = listItem3.getReact(({ props }) => props.id); 103 | 104 | await t 105 | .expect(appReact.props).eql({ label: 'AppLabel' }) 106 | 107 | .expect(listItem1React.props).eql({ id: 'l1-item1', selected: false }) 108 | .expect(listItem2React.props).eql({ id: 'l1-item2' }) 109 | 110 | .expect(listItem3Id).eql('l1-item3'); 111 | }); 112 | 113 | test('Should get component key', async t => { 114 | const listItem1React = await ReactSelector('ListItem').getReact(); 115 | const listItem2React = await ReactSelector('ListItem').nth(1).getReact(); 116 | const listItem3React = await ReactSelector('ListItem').nth(2).getReact(); 117 | 118 | await t 119 | .expect(listItem1React.key).eql('ListItem1') 120 | .expect(listItem2React.key).eql('ListItem2') 121 | .expect(listItem3React.key).eql(null); 122 | 123 | const listItem1LiTagReact = await ReactSelector('ListItem p').getReact(); 124 | const listItem2LiTagReact = await ReactSelector('ListItem p').nth(1).getReact(); 125 | const listItem3LiTagReact = await ReactSelector('ListItem p').nth(2).getReact(); 126 | 127 | await t 128 | .expect(listItem1LiTagReact.key).eql('l1-item1-p') 129 | .expect(listItem2LiTagReact.key).eql('l1-item2-p') 130 | .expect(listItem3LiTagReact.key).eql('l1-item3-p'); 131 | }); 132 | 133 | test('Should throw exception if version of React js is not supported', async t => { 134 | await ClientFunction(() => { 135 | let reactRoot = null; 136 | let internalReactProp = null; 137 | let internalReactPropReact18 = null; 138 | 139 | if (version === 18) { 140 | reactRoot = document.querySelector('#app'); 141 | internalReactPropReact18 = Object.keys(reactRoot).filter(prop => /^__reactContainer/.test(prop))[0]; 142 | } 143 | else { 144 | reactRoot = document.querySelector('#app-container'); 145 | internalReactProp = '_reactRootContainer'; 146 | } 147 | 148 | delete reactRoot[internalReactProp]; 149 | delete reactRoot[internalReactPropReact18]; 150 | }, { dependencies: { version } })(); 151 | 152 | try { 153 | await ReactSelector('App'); 154 | } 155 | catch (e) { 156 | await t.expect(e.errMsg).contains('This module supports React version 16.x and newer'); 157 | } 158 | }); 159 | 160 | test('Should get component from wrapper component - Regression GH-11', async t => { 161 | await t 162 | .expect(ReactSelector('TextLabel').textContent).eql('Component inside of wrapper component') 163 | .expect(ReactSelector('WrapperComponent').textContent).eql('Component inside of wrapper component'); 164 | }); 165 | 166 | test('Should not get dom nodes from nested components', async t => { 167 | const expectedListItemCount = listMap[version] * itemsInList; 168 | 169 | await t 170 | .expect(ReactSelector('ListItem p').count).eql(expectedListItemCount) 171 | .expect(ReactSelector('List p').count).eql(0) 172 | .expect(ReactSelector('App ListItem').count).eql(expectedListItemCount); 173 | }); 174 | 175 | test('Should get props and state from components with common DOM node - Regression GH-15', async t => { 176 | await t.expect(ReactSelector('WrapperComponent') 177 | .getReact(({ props, state }) => { 178 | return { direction: props.direction, width: state.width }; 179 | })) 180 | .eql({ direction: 'horizontal', width: 100 }); 181 | 182 | await t.expect(ReactSelector('TextLabel') 183 | .getReact(({ props, state }) => { 184 | return { color: props.color, text: state.text }; 185 | })) 186 | .eql({ color: '#fff', text: 'Component inside of wrapper component' }); 187 | }); 188 | 189 | test('Should get the component with empty output', async t => { 190 | const component = await ReactSelector('EmptyComponent'); 191 | 192 | await t.expect(component.getReact(({ state }) => state.id)).eql(1); 193 | }); 194 | 195 | test('Should search inside of portal component', async t => { 196 | //NOTE: react 18: react-dom/client does not provide ReactDOM.createPortal 197 | if (version === 18) return; 198 | 199 | const portal = ReactSelector('Portal'); 200 | const portalWidth = await portal.getReact(({ state }) => state.width); 201 | const list = ReactSelector('Portal List'); 202 | const listItem = ReactSelector('Portal ListItem'); 203 | const listId = await list.getReact(({ props }) => props.id); 204 | const pureComponent1 = ReactSelector('PortalWithPureComponent PureComponent'); 205 | const pureComponent2 = ReactSelector('PureComponent'); 206 | 207 | await t 208 | .expect(portal.exists).ok() 209 | .expect(portalWidth).eql(100) 210 | .expect(list.exists).ok() 211 | .expect(listItem.exists).ok() 212 | .expect(listId).eql('l3') 213 | .expect(pureComponent1.exists).ok() 214 | .expect(pureComponent2.exists).ok() 215 | .expect(portal.findReact('List').exists).ok(); 216 | 217 | await t 218 | .expect(ReactSelector('PortalReact16').exists).ok() 219 | .expect(ReactSelector('PortalReact16 List').exists).ok() 220 | .expect(ReactSelector('PortalReact16 ListItem').count).eql(3) 221 | .expect(ReactSelector('PortalReact16').findReact('List').exists).ok() 222 | .expect(ReactSelector('PortalReact16').findReact('ListItem').exists).ok() 223 | .expect(ReactSelector('PortalReact16').findReact('List ListItem').exists).ok() 224 | .expect(ReactSelector('PortalReact16').findReact('List').findReact('ListItem').exists).ok() 225 | .expect(ReactSelector('PortalReact16').findReact('PortalReact16').exists).notOk(); 226 | }); 227 | 228 | test('Should get new values of props and state after they were changed GH-71', async t => { 229 | const list = ReactSelector('List'); 230 | const isListActive = list.getReact(({ state }) => state.isActive); 231 | const isListItemSelected = ReactSelector('ListItem').getReact(({ props }) => props.selected); 232 | 233 | await t 234 | .expect(isListActive).eql(false) 235 | .expect(isListItemSelected).eql(false) 236 | 237 | //NOTE change List state and ListItem props 238 | .click(list) 239 | .expect(isListActive).eql(true) 240 | .expect(isListItemSelected).eql(true); 241 | }); 242 | 243 | test('Should get new values of props after they were changed in stateless components GH-74', async t => { 244 | const componentCont = ReactSelector('SmartComponent'); 245 | const statelessComp = ReactSelector('SmartComponent Stateless1'); 246 | const text = statelessComp.getReact(({ props }) => props.text); 247 | //NOTE test the getting props after the filtration 248 | const textPropDisabled = statelessComp.withText('Disabled').getReact(({ props }) => props.text); 249 | const textPropEnabled = statelessComp.withText('Enabled').getReact(({ props }) => props.text); 250 | 251 | await t 252 | .expect(text).eql('Disabled') 253 | .expect(textPropDisabled).eql('Disabled') 254 | .click(componentCont) 255 | 256 | .expect(text).eql('Enabled') 257 | .expect(textPropEnabled).eql('Enabled'); 258 | }); 259 | 260 | test('Should filter components by props (withProps method) - exact matching', async t => { 261 | const el = ReactSelector('UnfilteredSet SetItem'); 262 | let elSet = el.withProps({ prop1: true }, { exactObjectMatch: true }); 263 | let elSubSet = elSet.withProps({ prop2: { enabled: true } }, { exactObjectMatch: true }); 264 | 265 | await t 266 | .expect(el.count).eql(5) 267 | .expect(elSet.count).eql(3) 268 | .expect(elSubSet.count).eql(1); 269 | 270 | elSet = el.withProps('prop1', true, { exactObjectMatch: true }); 271 | elSubSet = elSet.withProps('prop2', { enabled: true }, { exactObjectMatch: true }); 272 | 273 | await t 274 | .expect(elSet.count).eql(3) 275 | .expect(elSubSet.count).eql(1); 276 | 277 | const circularDeps = { field: null }; 278 | 279 | circularDeps.field = circularDeps; 280 | 281 | const nonExistingSubset1 = el.withProps({ foo: 'bar' }, { exactObjectMatch: true }); 282 | const nonExistingSubset2 = el.withProps({ 283 | foo: function () { 284 | }, 285 | 286 | prop1: true 287 | }, { exactObjectMatch: true }); 288 | const nonExistingSubset3 = el.withProps({ 289 | prop1: true, 290 | prop2: { enabled: true, width: void 0 } 291 | }, { exactObjectMatch: true }); 292 | 293 | const nonExistingSubset4 = el.withProps({ 294 | prop1: true, 295 | prop2: [{ enabled: true }] 296 | }, { exactObjectMatch: true }); 297 | 298 | const nonExistingSubset5 = el.withProps(circularDeps, { exactObjectMatch: true }); 299 | 300 | await t 301 | .expect(nonExistingSubset1.count).eql(0) 302 | .expect(nonExistingSubset2.count).eql(0) 303 | .expect(nonExistingSubset3.count).eql(0) 304 | .expect(nonExistingSubset4.count).eql(0) 305 | .expect(nonExistingSubset5.count).eql(0); 306 | }); 307 | 308 | test('Should filter components by props (withProps method) - partial matching', async t => { 309 | const el = ReactSelector('UnfilteredSet_PartialMatching SetItem'); 310 | 311 | const subSet1 = el.withProps({ 312 | prop1: { 313 | obj: { field1: 1 } 314 | } 315 | }); 316 | 317 | const subSet2 = el.withProps({ 318 | prop1: { 319 | obj: { field2: 0 } 320 | } 321 | }); 322 | 323 | const subSet3 = el.withProps({ 324 | prop1: { 325 | obj: { 326 | field1: 2, 327 | notExistingField: true 328 | } 329 | } 330 | }); 331 | 332 | await t 333 | .expect(subSet1.count).eql(3) 334 | .expect(subSet2.count).eql(1) 335 | .expect(subSet3.count).eql(0); 336 | 337 | const subSetLevel2Partial = el.withProps({ 338 | prop1: { 339 | obj: { 340 | field3: { 341 | subField1: 1 342 | } 343 | } 344 | } 345 | }); 346 | 347 | const subSetLevel2Exact = el.withProps({ 348 | prop1: { 349 | obj: { 350 | field3: { 351 | subField1: 1, 352 | subField2: 0 353 | } 354 | } 355 | } 356 | }); 357 | 358 | await t 359 | .expect(subSetLevel2Partial.count).eql(3) 360 | .expect(subSetLevel2Exact.count).eql(1); 361 | }); 362 | 363 | test('Should filter components by props (withProps method) - errors', async t => { 364 | const el = ReactSelector('List'); 365 | 366 | const nonObjectValues = [null, false, void 0, 42, 'prop', []]; 367 | const nonStringValues = [null, false, void 0, 42, []]; 368 | 369 | for (const props of nonObjectValues) { 370 | try { 371 | await el.withProps(props).with({ timeout: 10 })(); 372 | } 373 | catch (e) { 374 | await t.expect(e.errMsg).contains(`Error: The "props" option value is expected to be a non-null object, but it is ${typeof props}.`); 375 | } 376 | } 377 | 378 | for (const props of nonStringValues) { 379 | try { 380 | await el.withProps(props, 'value').with({ timeout: 10 })(); 381 | } 382 | catch (e) { 383 | await t.expect(e.errMsg).contains(`Error: The first argument is expected to be a property name string or a "props" non-null object, but it is ${typeof props}.`); 384 | } 385 | } 386 | 387 | for (const options of nonObjectValues) { 388 | try { 389 | await el.withProps('prop', 'value', options).with({ timeout: 10 })(); 390 | } 391 | catch (e) { 392 | await t.expect(e.errMsg).contains(`Error: The "options" value is expected to be an object, but it is ${typeof options}.`); 393 | } 394 | } 395 | 396 | for (const options of nonObjectValues) { 397 | try { 398 | await el.withProps({ prop: 'value' }, options).with({ timeout: 10 })(); 399 | } 400 | catch (e) { 401 | await t.expect(e.errMsg).contains(`Error: The "options" value is expected to be an object, but it is ${typeof options}.`); 402 | } 403 | } 404 | }); 405 | 406 | test('Should filter components by key', async t => { 407 | const expectedItemCount = listMap[version]; 408 | const listItemsByKey = ReactSelector('ListItem').withKey('ListItem1'); 409 | const emptySet1 = ReactSelector('ListItem').withKey(void 0); 410 | const emptySet2 = ReactSelector('ListItem').withKey(null); 411 | 412 | await t 413 | .expect(listItemsByKey.count).eql(expectedItemCount) 414 | .expect(emptySet1.count).eql(0) 415 | .expect(emptySet2.count).eql(0); 416 | 417 | if (version < 18) { 418 | await t 419 | .expect(ReactSelector('Portal').withKey('portal').count).eql(1); 420 | } 421 | 422 | await t 423 | .expect(listItemsByKey.withProps({ selected: false }).count).eql(expectedItemCount) 424 | .click(listItemsByKey) 425 | 426 | .expect(listItemsByKey.withProps({ selected: false }).count).eql(expectedItemCount - 1) 427 | .expect(listItemsByKey.withProps({ selected: true }).count).eql(1); 428 | 429 | if (version < 18) 430 | await t.expect(ReactSelector('PortalReact16').withKey('portalReact16').count).eql(1); 431 | }); 432 | 433 | test('Should find subcomponents (findReact methods)', async t => { 434 | const smartComponent = ReactSelector('App').findReact('SmartComponent'); 435 | const textLabel = ReactSelector('App').findReact('WrapperComponent TextLabel'); 436 | const list = ReactSelector('List'); 437 | const listItems = list.findReact('ListItem'); 438 | 439 | const paragraphs1 = listItems.findReact('li p'); 440 | const paragraphs2 = listItems.findReact('p'); 441 | const expectedElCount = listMap[version] * itemsInList; 442 | 443 | await t 444 | .expect(smartComponent.exists).ok() 445 | .expect(textLabel.exists).ok() 446 | 447 | .expect(listItems.count).eql(expectedElCount) 448 | .expect(paragraphs1.count).eql(expectedElCount) 449 | .expect(paragraphs2.count).eql(expectedElCount); 450 | }); 451 | 452 | test('Should find subcomponents (findReact methods) - errors', async t => { 453 | for (const selector of [null, false, void 0, {}, 42]) { 454 | try { 455 | await ReactSelector('app').findReact(selector); 456 | } 457 | catch (e) { 458 | await t.expect(e.errMsg).contains(`Selector option is expected to be a string, but it was ${typeof selector}.`); 459 | } 460 | } 461 | }); 462 | 463 | test('Should find subcomponents (combining findReact and withProps)', async t => { 464 | const spanText = 'SetItem2'; 465 | const el = ReactSelector('SetItem'); 466 | const elSet = el.withProps({ prop1: true }); 467 | const subEl = elSet.findReact('span'); 468 | const subElByProps = el.findReact('SetItemLabel').withProps('text', spanText); 469 | const actualText = subElByProps.getReact(({ props }) => props.text); 470 | 471 | await t 472 | .expect(elSet.count).eql(3) 473 | .expect(subEl.count).eql(2) 474 | .expect(subEl.tagName).eql('span') 475 | .expect(el.findReact('SetItemLabel').count).eql(3) 476 | .expect(subElByProps.count).eql(1) 477 | .expect(actualText).eql(spanText); 478 | }); 479 | 480 | test('Should find react components inside of filtered Selector set GH-97', async t => { 481 | await t 482 | .expect(ReactSelector('List').withText('List: l2').findReact('ListItem').id).eql('l2-item1') 483 | .expect(ReactSelector('App').find('div').exists).ok() 484 | .expect(ReactSelector('App').find('div').findReact('ListItem').exists).ok() 485 | .expect(ReactSelector('App').find('div').findReact('ListItem').count).eql(6) 486 | .expect(ReactSelector('App').find('*').findReact('ListItem').count).eql(6) 487 | .expect(ReactSelector('App').find('div').findReact('ListItem').id).eql('l1-item1'); 488 | 489 | const componentCont = ReactSelector('SmartComponent'); 490 | const statelessComp = componentCont.findReact('Stateless1'); 491 | const text = statelessComp.getReact(({ props }) => props.text); 492 | 493 | await t 494 | .expect(text).eql('Disabled') 495 | 496 | .click(componentCont) 497 | .expect(text).eql('Enabled') 498 | 499 | .click(componentCont) 500 | .expect(text).eql('Disabled'); 501 | }); 502 | 503 | test('waitForReact should work from a node callback', async t => { 504 | fs.exists('../../packpage.json', async () => { 505 | await waitForReact(1e4, t); 506 | }); 507 | }); 508 | 509 | test('Should find react components inside nested react app', async t => { 510 | await t 511 | .expect(ReactSelector('NestedApp Stateless1').withText('Inside nested app').exists).ok(); 512 | }); 513 | 514 | test('Should find children of a React.Suspense', async t => { 515 | if (version !== 18) return; 516 | await t.expect(ReactSelector('AsyncChild').exists).ok(); 517 | }); 518 | 519 | fixture`ReactJS TestCafe plugin (the app loads during test) (React ${version})` 520 | .page`http://localhost:3000`; 521 | 522 | test('Should search inside of stateless root GH-33', async t => { 523 | const expectedText = 'PureComponent'; 524 | 525 | await t.navigateTo('./stateless-root.html'); 526 | await waitForReact(); 527 | 528 | let App = ReactSelector('App'); 529 | let component1 = ReactSelector('App PureComponent'); 530 | let component2 = ReactSelector('PureComponent'); 531 | let appTitle = App.getReact(({ props }) => props.text); 532 | const text1 = component1.getReact(({ state }) => state.text); 533 | const text2 = component2.getReact(({ state }) => state.text); 534 | 535 | await t 536 | .expect(App.exists).ok() 537 | .expect(component1.exists).ok() 538 | .expect(component2.exists).ok() 539 | .expect(appTitle).eql('AppTitle') 540 | .expect(text1).eql(expectedText) 541 | .expect(text2).eql(expectedText); 542 | 543 | await t.navigateTo('./root-pure-component.html'); 544 | await waitForReact(); 545 | 546 | App = ReactSelector('App'); 547 | component1 = ReactSelector('App PureComponent'); 548 | component2 = ReactSelector('PureComponent'); 549 | appTitle = App.getReact(({ props }) => props.text); 550 | const text = App.getReact(({ state }) => state.text); 551 | const component1React = component1.getReact(); 552 | const component2React = component2.getReact(); 553 | 554 | await t 555 | .expect(App.exists).ok() 556 | .expect(component1.exists).ok() 557 | .expect(component2.exists).ok() 558 | .expect(appTitle).eql('AppTitle') 559 | .expect(text).eql(expectedText) 560 | .expect(component1React).eql({ state: {}, props: {}, key: null }) 561 | .expect(component2React).eql({ state: {}, props: {}, key: null }); 562 | }); 563 | 564 | test('Should throw exception if there is no React on the tested page', async t => { 565 | await t.navigateTo('./noReact'); 566 | 567 | try { 568 | await ReactSelector('body'); 569 | } 570 | catch (e) { 571 | await t.expect(e.errMsg).contains('This module supports React version 16.x and newer'); 572 | } 573 | }); 574 | } 575 | /*eslint-enable no-loop-func*/ 576 | -------------------------------------------------------------------------------- /test/fixtures/server-rendering.js: -------------------------------------------------------------------------------- 1 | /*global fixture test*/ 2 | import { ReactSelector, waitForReact } from '../../'; 3 | 4 | fixture `Server rendering` 5 | .page `http://localhost:1355/serverRender` 6 | .beforeEach(waitForReact); 7 | 8 | test('Should get component inside server rendered root node (React 16) - GH-69', async t => { 9 | const labelText = ReactSelector('Label').getReact(({ state }) => state.text); 10 | 11 | await t.expect(labelText).eql('Label Text...'); 12 | }); 13 | -------------------------------------------------------------------------------- /test/fixtures/typescript.ts: -------------------------------------------------------------------------------- 1 | /*global fixture test*/ 2 | import { ReactSelector, ReactComponent } from '../../'; 3 | 4 | fixture`TypeScript` 5 | .page('http://localhost:3000/index-react-18.html'); 6 | 7 | test('Should get DOM node by react selector', async t => { 8 | const listItem1 = await ReactSelector('ListItem').nth(0); 9 | const listItem2 = ReactSelector('ListItem').nth(1); 10 | 11 | type ListItemComponent = ReactComponent<{ id: string }>; 12 | 13 | const listItem1Id = (await listItem1.getReact()).props.id; 14 | const listItem2Id = listItem2.getReact(( { props } ) => props.id); 15 | 16 | await t 17 | .expect(listItem1.id).eql('l1-item1') 18 | .expect(listItem2.id).eql('l1-item2') 19 | 20 | .expect(listItem1Id).eql('l1-item1') 21 | .expect(listItem2Id).eql('l1-item2'); 22 | }); 23 | -------------------------------------------------------------------------------- /test/helpers/service-util.js: -------------------------------------------------------------------------------- 1 | /*global window document*/ 2 | import { ClientFunction, t } from 'testcafe'; 3 | import { waitForReact } from '../../'; 4 | 5 | export async function loadApp (version) { 6 | const loadScript = ClientFunction(src => { 7 | if (!src) src = window.appSrc; 8 | 9 | const head = document.getElementsByTagName('head')[0]; 10 | const script = document.createElement('script'); 11 | 12 | script.type = 'text/javascript'; 13 | script.async = false; 14 | script.src = src; 15 | 16 | head.appendChild(script); 17 | }); 18 | 19 | await loadScript('./vendor/react-' + version + '/react.js'); 20 | await t.expect(ClientFunction(() => !!window.React)()).ok(); 21 | 22 | await loadScript('./vendor/react-' + version + '/react-dom.js'); 23 | await t.expect(ClientFunction(() => !!window.ReactDOM)()).ok(); 24 | 25 | await loadScript(); 26 | 27 | await waitForReact(3e4); 28 | } 29 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const next = require('next'); 4 | 5 | const TEST_RESOURCES_PORT = 1355; 6 | 7 | module.exports = function () { 8 | return new Promise(resolve => { 9 | const nextjsApp = next({ dir: path.join(__dirname, './data/server-render') }); 10 | 11 | // NOTE: https://nextjs.org/docs/pages/building-your-application/configuring/custom-server 12 | return nextjsApp.prepare() 13 | .then(() => { 14 | return nextjsApp.getRequestHandler(); 15 | }) 16 | .then(handle => { 17 | express() 18 | .use(express.static(path.join(__dirname, './data'))) 19 | .disable('view cache') 20 | .get('/serverRender', (req, res) => { 21 | nextjsApp.render(req, res, '/'); 22 | }) 23 | .get('*', (req, res) => handle(req, res)) 24 | .listen(TEST_RESOURCES_PORT, resolve); 25 | }); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /ts-defs/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | 3 | export type ReactComponent< 4 | P extends { [name: string]: any }, 5 | S extends object | { [name: string]: any } = {}, 6 | K = string 7 | > = { 8 | props: P; 9 | state?: S, 10 | key?: K; 11 | }; 12 | 13 | export type DefaultReactComponent = ReactComponent<{ [name: string]: any }>; 14 | 15 | declare global { 16 | interface Selector { 17 | getReact(filter?: (reactInternal: C) => T): Promise; 18 | getReact(): Promise; 19 | 20 | withProps

    (propName: keyof P, propValue?: Partial, options?: { exactObjectMatch: boolean }): any; 21 | 22 | withProps

    (props: Partial

    , options?: { exactObjectMatch: boolean }): any; 23 | 24 | withKey(key: string): any; 25 | 26 | findReact(selector: string): Selector; 27 | } 28 | } 29 | 30 | export function ReactSelector(selector: string): Selector 31 | 32 | export function waitForReact(timeout?: number, testController?: any): Promise 33 | --------------------------------------------------------------------------------