├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── feature.md │ ├── question.md │ └── regression.md ├── PULL_REQUEST_TEMPLATE.md ├── SUPPORT.md └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .swcrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── conventional-github-releaser-context.json ├── jest-puppeteer.config.js ├── jest.config.js ├── lerna.json ├── nx.json ├── package-lock.json ├── package.json ├── packages ├── expect-puppeteer │ ├── .npmignore │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── __fixtures__ │ │ └── file.txt │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.mjs │ ├── src │ │ ├── __snapshots__ │ │ │ └── index.test.ts.snap │ │ ├── globals.test.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── matchers │ │ │ ├── getElementFactory.ts │ │ │ ├── matchTextContent.ts │ │ │ ├── notToMatchElement.test.ts │ │ │ ├── notToMatchElement.ts │ │ │ ├── notToMatchTextContent.test.ts │ │ │ ├── notToMatchTextContent.ts │ │ │ ├── test-util.ts │ │ │ ├── toClick.test.ts │ │ │ ├── toClick.ts │ │ │ ├── toDisplayDialog.test.ts │ │ │ ├── toDisplayDialog.ts │ │ │ ├── toFill.test.ts │ │ │ ├── toFill.ts │ │ │ ├── toFillForm.test.ts │ │ │ ├── toFillForm.ts │ │ │ ├── toMatchElement.test.ts │ │ │ ├── toMatchElement.ts │ │ │ ├── toMatchTextContent.test.ts │ │ │ ├── toMatchTextContent.ts │ │ │ ├── toSelect.test.ts │ │ │ ├── toSelect.ts │ │ │ ├── toUploadFile.test.ts │ │ │ └── toUploadFile.ts │ │ ├── options.test.ts │ │ ├── options.ts │ │ └── utils.ts │ └── tsconfig.json ├── jest-dev-server │ ├── .npmignore │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── rollup.config.mjs │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── jest-environment-puppeteer │ ├── .npmignore │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.mjs │ ├── setup.js │ ├── src │ │ ├── browsers.ts │ │ ├── config.ts │ │ ├── env.ts │ │ ├── global-init.ts │ │ ├── index.ts │ │ └── stdin.ts │ ├── teardown.js │ ├── tests │ │ ├── .eslintrc.js │ │ ├── __fixtures__ │ │ │ ├── browserConfig.js │ │ │ ├── browserWsEndpointConfig.js │ │ │ ├── customConfig.js │ │ │ ├── invalidProduct.js │ │ │ ├── launchConfig.js │ │ │ └── promiseConfig.js │ │ ├── basic.test.ts │ │ ├── browserContext-1.test.ts │ │ ├── browserContext-2.test.ts │ │ ├── config.test.ts │ │ ├── resetBrowser.test.ts │ │ ├── resetPage.test.ts │ │ └── runBeforeUnloadOnClose.test.ts │ └── tsconfig.json ├── jest-puppeteer │ ├── .npmignore │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── jest-preset.js │ ├── package.json │ ├── rollup.config.mjs │ ├── src │ │ └── index.ts │ └── tsconfig.json └── spawnd │ ├── .npmignore │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── rollup.config.mjs │ ├── src │ └── index.ts │ └── tsconfig.json ├── resources ├── jest-puppeteer-logo.png └── jest-puppeteer-logo.sketch ├── server ├── .eslintrc.js ├── index.js └── public │ ├── frame.html │ ├── index.html │ ├── page2.html │ ├── shadow.html │ └── shadowFrame.html └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es6: true, 5 | node: true, 6 | browser: true, 7 | }, 8 | extends: ["eslint:recommended"], 9 | parserOptions: { 10 | ecmaVersion: 2022, 11 | sourceType: "module", 12 | }, 13 | globals: { 14 | jestPuppeteer: "readonly", 15 | page: "readonly", 16 | }, 17 | 18 | overrides: [ 19 | { 20 | files: ["*.test.?(m|t)js"], 21 | env: { 22 | "jest/globals": true, 23 | }, 24 | plugins: ["jest"], 25 | }, 26 | { 27 | files: ["*.ts"], 28 | extends: "plugin:@typescript-eslint/recommended", 29 | parser: "@typescript-eslint/parser", 30 | parserOptions: { 31 | project: [ 32 | "./tsconfig.json", 33 | "./packages/expect-puppeteer/tsconfig.json", 34 | "./packages/jest-dev-server/tsconfig.json", 35 | "./packages/jest-environment-server/tsconfig.json", 36 | "./packages/jest-puppeteer/tsconfig.json", 37 | ], 38 | tsconfigRootDir: __dirname, 39 | }, 40 | plugins: ["@typescript-eslint"], 41 | rules: { 42 | "@typescript-eslint/no-explicit-any": "warn", 43 | }, 44 | }, 45 | ], 46 | }; 47 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: gregberge 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 👉 [Please follow one of these issue templates](https://github.com/smooth-code/jest-puppeteer/issues/new/choose) 👈 2 | 3 | 4 | 5 | Note: to keep the backlog clean and actionable, issues may be immediately closed if they do not follow one of the above issue templates. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | 7 | 8 | ## 🐛 Bug Report 9 | 10 | A clear and concise description of what the bug is. 11 | 12 | ## To Reproduce 13 | 14 | Steps to reproduce the behavior: 15 | 16 | ## Expected behavior 17 | 18 | A clear and concise description of what you expected to happen. 19 | 20 | ## Link to repl or repo (highly encouraged) 21 | 22 | Please provide a minimal repository on GitHub. 23 | 24 | Issues without a reproduction link are likely to stall. 25 | 26 | ## Run `npx envinfo --system --binaries --npmPackages expect-puppeteer,jest-dev-server,jest-environment-puppeteer,jest-puppeteer,spawnd --markdown --clipboard` 27 | 28 | Paste the results here: 29 | 30 | ```bash 31 | 32 | ``` 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature Proposal 3 | about: Submit a proposal for a new feature 4 | --- 5 | 6 | 7 | 8 | ## 🚀 Feature Proposal 9 | 10 | A clear and concise description of what the feature is. 11 | 12 | ## Motivation 13 | 14 | Please outline the motivation for the proposal. 15 | 16 | ## Example 17 | 18 | Please provide an example for how this feature would be used. 19 | 20 | ## Pitch 21 | 22 | Why does this feature belong in the Jest Puppeteer ecosystem? 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 💬 Questions / Help 3 | about: If you have questions, please read full readme first 4 | --- 5 | 6 | 7 | 8 | ## 💬 Questions and Help 9 | 10 | Jest Puppeteer project is young, but please before asking your question: 11 | 12 | - Read carefully the README of the project 13 | - Search if your answer has already been answered in old issues 14 | 15 | After you can submit your question and we will be happy to help you! 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/regression.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 💥 Regression Report 3 | about: Report unexpected behavior that worked in previous versions 4 | --- 5 | 6 | 7 | 8 | ## 💥 Regression Report 9 | 10 | A clear and concise description of what the regression is. 11 | 12 | ## Last working version 13 | 14 | Worked up to version: 15 | 16 | Stopped working in version: 17 | 18 | ## To Reproduce 19 | 20 | Steps to reproduce the behavior: 21 | 22 | ## Expected behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | ## Link to repl or repo (highly encouraged) 27 | 28 | Please provide a minimal repository on GitHub. 29 | 30 | Issues without a reproduction link are likely to stall. 31 | 32 | ## Run `npx envinfo --system --binaries --npmPackages expect-puppeteer,jest-dev-server,jest-environment-puppeteer,jest-puppeteer,spawnd --markdown --clipboard` 33 | 34 | Paste the results here: 35 | 36 | ```bash 37 | 38 | ``` 39 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Summary 4 | 5 | 6 | 7 | ## Test plan 8 | 9 | 10 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | Please read carefully the README before asking questions. 2 | 3 | 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | on: 3 | pull_request: 4 | branches: [main] 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | Test: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | node-version: [18, 20, 22, "latest"] 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm ci 21 | - run: npm run build 22 | - run: npm run lint 23 | - run: npm run test -- --ci 24 | - run: npm run test:incognito -- --ci 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | screenshots/ 4 | .nx/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | package.json 3 | lerna.json 4 | CHANGELOG.md 5 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "jsc": { 4 | "target": "es2022", 5 | "parser": { 6 | "syntax": "typescript" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contact@smooth-code.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | jest-puppeteer is a small project, it is widely used but has not a lot of contributors. We're still working out the kinks to make contributing to this project as easy and transparent as possible, but we're not quite there yet. Hopefully this document makes the process for contributing clear and answers some questions that you may have. 4 | 5 | ## [Code of Conduct](https://github.com/smooth-code/jest-puppeteer/blob/master/CODE_OF_CONDUCT.md) 6 | 7 | We expect project participants to adhere to our Code of Conduct. Please read [the full text](https://github.com/smooth-code/jest-puppeteer/blob/master/CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated. 8 | 9 | ## Open Development 10 | 11 | All work on jest-puppeteer happens directly on [GitHub](/). Both core team members and external contributors send pull requests which go through the same review process. 12 | 13 | ### Workflow and Pull Requests 14 | 15 | _Before_ submitting a pull request, please make sure the following is done… 16 | 17 | 1. Fork the repo and create your branch from `master`. A guide on how to fork a repository: https://help.github.com/articles/fork-a-repo/ 18 | 19 | Open terminal (e.g. Terminal, iTerm, Git Bash or Git Shell) and type: 20 | 21 | ```sh-session 22 | $ git clone https://github.com//jest-puppeteer 23 | $ cd jest-puppeteer 24 | $ git checkout -b my_branch 25 | ``` 26 | 27 | Note: Replace `` with your GitHub username 28 | 29 | 2. Run `npm install`. 30 | 31 | 3. If you've changed APIs, update the documentation. 32 | 33 | 4. Ensure the linting is good via `npm run lint`. 34 | 35 | 5. Ensure the test suite passes via `npm run test`. 36 | 37 | ## Bugs 38 | 39 | ### Where to Find Known Issues 40 | 41 | We will be using GitHub Issues for our public bugs. We will keep a close eye on this and try to make it clear when we have an internal fix in progress. Before filing a new issue, try to make sure your problem doesn't already exist. 42 | 43 | ### Reporting New Issues 44 | 45 | The best way to get your bug fixed is to provide a reduced test case. Please provide a public repository with a runnable example. 46 | 47 | ## Code Conventions 48 | 49 | Please follow the `.prettierrc` in the project. 50 | 51 | ## License 52 | 53 | By contributing to jest-puppeteer, you agree that your contributions will be licensed under its MIT license. 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Smooth Code 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Let's find a balance between detailed explanations and clarity. Here’s a more comprehensive version that retains structure but elaborates more where needed: 2 | 3 | --- 4 | 5 | # 🎪 jest-puppeteer 6 | 7 | [![npm version](https://img.shields.io/npm/v/jest-puppeteer.svg)](https://www.npmjs.com/package/jest-puppeteer) 8 | [![npm downloads](https://img.shields.io/npm/dm/jest-puppeteer.svg)](https://www.npmjs.com/package/jest-puppeteer) 9 | 10 | `jest-puppeteer` is a Jest preset designed for seamless integration with Puppeteer, enabling end-to-end testing in a browser environment. With a simple API, it allows you to launch browsers and interact with web pages, making it perfect for testing UI interactions in web applications. 11 | 12 | ## Table of Contents 13 | 14 | 1. [Getting Started](#getting-started) 15 | - [Installation](#installation) 16 | - [Basic Setup](#basic-setup) 17 | - [Writing Your First Test](#writing-your-first-test) 18 | - [TypeScript Setup](#typescript-setup) 19 | - [Visual Testing with Argos](#visual-testing-with-argos) 20 | 2. [Recipes](#recipes) 21 | - [Using `expect-puppeteer`](#using-expect-puppeteer) 22 | - [Debugging Tests](#debugging-tests) 23 | - [Automatic Server Management](#automatic-server-management) 24 | - [Customizing the Puppeteer Instance](#customizing-the-puppeteer-instance) 25 | - [Custom Test Setup](#custom-test-setup) 26 | - [Extending `PuppeteerEnvironment`](#extending-puppeteerenvironment) 27 | - [Global Setup and Teardown](#global-setup-and-teardown) 28 | 3. [Jest-Puppeteer Configuration](#jest-puppeteer-configuration) 29 | 4. [API Reference](#api-reference) 30 | 5. [Troubleshooting](#troubleshooting) 31 | 6. [Acknowledgements](#acknowledgements) 32 | 33 | ## Getting Started 34 | 35 | ### Installation 36 | 37 | To start using `jest-puppeteer`, you’ll need to install the following packages: 38 | 39 | ```bash 40 | npm install --save-dev jest-puppeteer puppeteer jest 41 | ``` 42 | 43 | This will install Jest (the testing framework), Puppeteer (the headless browser tool), and `jest-puppeteer` (the integration between the two). 44 | 45 | ### Basic Setup 46 | 47 | In your Jest configuration file (`jest.config.js`), add `jest-puppeteer` as the preset: 48 | 49 | ```json 50 | { 51 | "preset": "jest-puppeteer" 52 | } 53 | ``` 54 | 55 | This will configure Jest to use Puppeteer for running your tests. Make sure to remove any conflicting `testEnvironment` settings that might be present in your existing Jest configuration, as `jest-puppeteer` manages the environment for you. 56 | 57 | ### Writing Your First Test 58 | 59 | Once you’ve configured Jest, you can start writing tests using Puppeteer’s `page` object, which is automatically provided by `jest-puppeteer`. 60 | 61 | Create a test file (e.g., `google.test.js`): 62 | 63 | ```js 64 | import "expect-puppeteer"; 65 | 66 | describe("Google Homepage", () => { 67 | beforeAll(async () => { 68 | await page.goto("https://google.com"); 69 | }); 70 | 71 | it('should display "google" text on page', async () => { 72 | await expect(page).toMatchTextContent(/Google/); 73 | }); 74 | }); 75 | ``` 76 | 77 | This example test navigates to Google’s homepage and checks if the page contains the word "Google". `jest-puppeteer` simplifies working with Puppeteer by exposing the `page` object, allowing you to write tests using a familiar syntax. 78 | 79 | ### TypeScript Setup 80 | 81 | If you’re using TypeScript, `jest-puppeteer` natively supports it from version `8.0.0`. To get started with TypeScript, follow these steps: 82 | 83 | 1. Make sure your project is using the correct type definitions. If you’ve upgraded to version `10.1.2` or above, uninstall old types: 84 | 85 | ```bash 86 | npm uninstall --save-dev @types/jest-environment-puppeteer @types/expect-puppeteer 87 | ``` 88 | 89 | 2. Install `@types/jest` (`jest-puppeteer` does not support `@jest/globals`) : 90 | 91 | ```bash 92 | npm install --save-dev @types/jest 93 | ``` 94 | 95 | 3. Jest will automatically pick up type definitions from `@types/jest`. Once you’ve set up the environment, you can start writing tests in TypeScript just like in JavaScript: 96 | 97 | ```ts 98 | import "jest-puppeteer"; 99 | import "expect-puppeteer"; 100 | 101 | describe("Google Homepage", (): void => { 102 | beforeAll(async (): Promise => { 103 | await page.goto("https://google.com"); 104 | }); 105 | 106 | it('should display "google" text on page', async (): Promise => { 107 | await expect(page).toMatchTextContent(/Google/); 108 | }); 109 | }); 110 | ``` 111 | 112 | ### Visual Testing with Argos 113 | 114 | [Argos](https://argos-ci.com) is a powerful tool for visual testing, allowing you to track visual changes introduced by each pull request. By integrating Argos with `jest-puppeteer`, you can easily capture and compare screenshots to maintain the visual consistency of your application. 115 | 116 | To get started, check out the [Puppeteer Quickstart Guide](https://argos-ci.com/docs/quickstart/puppeteer). 117 | 118 | ## Recipes 119 | 120 | ### Using `expect-puppeteer` 121 | 122 | Writing tests with Puppeteer’s core API can be verbose. The `expect-puppeteer` library simplifies this by adding custom matchers, such as checking for text content or interacting with elements. Some examples: 123 | 124 | - Assert that a page contains certain text: 125 | 126 | ```js 127 | await expect(page).toMatchTextContent("Expected text"); 128 | ``` 129 | 130 | - Simulate a button click: 131 | 132 | ```js 133 | await expect(page).toClick("button", { text: "Submit" }); 134 | ``` 135 | 136 | - Fill out a form: 137 | 138 | ```js 139 | await expect(page).toFillForm('form[name="login"]', { 140 | username: "testuser", 141 | password: "password", 142 | }); 143 | ``` 144 | 145 | ### Debugging Tests 146 | 147 | Debugging can sometimes be tricky in headless browser environments. `jest-puppeteer` provides a helpful `debug()` function, which pauses test execution and opens the browser for manual inspection: 148 | 149 | ```js 150 | await jestPuppeteer.debug(); 151 | ``` 152 | 153 | To prevent the test from timing out, increase Jest’s timeout: 154 | 155 | ```js 156 | jest.setTimeout(300000); // 5 minutes 157 | ``` 158 | 159 | This can be particularly useful when you need to step through interactions or inspect the state of the page during test execution. 160 | 161 | ### Automatic Server Management 162 | 163 | If your tests depend on a running server (e.g., an Express app), you can configure `jest-puppeteer` to automatically start and stop the server before and after tests: 164 | 165 | ```js 166 | module.exports = { 167 | server: { 168 | command: "node server.js", 169 | port: 4444, 170 | }, 171 | }; 172 | ``` 173 | 174 | This eliminates the need to manually manage your server during testing. 175 | 176 | ### Customizing the Puppeteer Instance 177 | 178 | You can easily customize the Puppeteer instance used in your tests by modifying the `jest-puppeteer.config.js` file. For example, if you want to launch Firefox instead of Chrome: 179 | 180 | ```js 181 | module.exports = { 182 | launch: { 183 | browser: "firefox", 184 | headless: process.env.HEADLESS !== "false", 185 | }, 186 | }; 187 | ``` 188 | 189 | This file allows you to configure browser options, set up browser contexts, and more. 190 | 191 | ### Custom Test Setup 192 | 193 | If you have custom setup requirements, you can define setup files to initialize your environment before each test. For instance, you may want to import `expect-puppeteer` globally: 194 | 195 | ```js 196 | // setup.js 197 | require("expect-puppeteer"); 198 | ``` 199 | 200 | Then, in your Jest config: 201 | 202 | ```js 203 | module.exports = { 204 | setupFilesAfterEnv: ["./setup.js"], 205 | }; 206 | ``` 207 | 208 | ### Extending `PuppeteerEnvironment` 209 | 210 | For advanced use cases, you can extend the default `PuppeteerEnvironment` class to add custom functionality: 211 | 212 | ```js 213 | const PuppeteerEnvironment = require("jest-environment-puppeteer"); 214 | 215 | class CustomEnvironment extends PuppeteerEnvironment { 216 | async setup() { 217 | await super.setup(); 218 | // Custom setup logic 219 | } 220 | 221 | async teardown() { 222 | // Custom teardown logic 223 | await super.teardown(); 224 | } 225 | } 226 | 227 | module.exports = CustomEnvironment; 228 | ``` 229 | 230 | ### Global Setup and Teardown 231 | 232 | Sometimes, tests may require a global setup or teardown step that only runs once per test suite. You can define custom `globalSetup` and `globalTeardown` scripts: 233 | 234 | ```js 235 | // global-setup.js 236 | const setupPuppeteer = require("jest-environment-puppeteer/setup"); 237 | 238 | module.exports = async function globalSetup(globalConfig) { 239 | await setupPuppeteer(globalConfig); 240 | // Additional setup logic 241 | }; 242 | ``` 243 | 244 | In your Jest configuration, reference these files: 245 | 246 | ```json 247 | { 248 | "globalSetup": "./global-setup.js", 249 | "globalTeardown": "./global-teardown.js" 250 | } 251 | ``` 252 | 253 | ### Jest-Puppeteer Configuration 254 | 255 | Jest-Puppeteer supports various configuration formats through [cosmiconfig](https://github.com/davidtheclark/cosmiconfig), allowing flexible ways to define your setup. By default, the configuration is looked for at the root of your project, but you can also define a custom path using the `JEST_PUPPETEER_CONFIG` environment variable. 256 | 257 | Possible configuration formats: 258 | 259 | - A `"jest-puppeteer"` key in your `package.json`. 260 | - A `.jest-puppeteerrc` file (JSON, YAML, or JavaScript). 261 | - A `.jest-puppeteer.config.js` or `.jest-puppeteer.config.cjs` file that exports a configuration object. 262 | 263 | Example of a basic configuration file (`jest-puppeteer.config.js`): 264 | 265 | ```js 266 | module.exports = { 267 | launch: { 268 | headless: process.env.HEADLESS !== "false", 269 | dumpio: true, // Show browser console logs 270 | }, 271 | browserContext: "default", // Use "incognito" if you want isolated sessions per test 272 | server: { 273 | command: "node server.js", 274 | port: 4444, 275 | launchTimeout: 10000, 276 | debug: true, 277 | }, 278 | }; 279 | ``` 280 | 281 | You can further extend this configuration to connect to a remote instance of Chrome or customize the environment for your test runs. 282 | 283 | ## API Reference 284 | 285 | Jest-Puppeteer exposes several global objects and methods to facilitate test writing: 286 | 287 | - **`global.browser`**: Provides access to the Puppeteer [Browser](https://pptr.dev/api/puppeteer.browser/) instance. 288 | 289 | Example: 290 | 291 | ```js 292 | const page = await browser.newPage(); 293 | await page.goto("https://example.com"); 294 | ``` 295 | 296 | - **`global.page`**: The default Puppeteer [Page](https://pptr.dev/api/puppeteer.page/) object, automatically created and available in tests. 297 | 298 | Example: 299 | 300 | ```js 301 | await page.type("#input", "Hello World"); 302 | ``` 303 | 304 | - **`global.context`**: Gives access to the [browser context](https://pptr.dev/api/puppeteer.browsercontext/), useful for isolating tests in separate contexts. 305 | 306 | - **`global.expect(page)`**: The enhanced `expect` API provided by `expect-puppeteer`. You can use this to make assertions on the Puppeteer `page`. 307 | 308 | Example: 309 | 310 | ```js 311 | await expect(page).toMatchTextContent("Expected text on page"); 312 | ``` 313 | 314 | - **`global.jestPuppeteer.debug()`**: Suspends test execution, allowing you to inspect the browser and debug. 315 | 316 | Example: 317 | 318 | ```js 319 | await jestPuppeteer.debug(); 320 | ``` 321 | 322 | - **`global.jestPuppeteer.resetPage()`**: Resets the `page` object before each test. 323 | 324 | Example: 325 | 326 | ```js 327 | beforeEach(async () => { 328 | await jestPuppeteer.resetPage(); 329 | }); 330 | ``` 331 | 332 | - **`global.jestPuppeteer.resetBrowser()`**: Resets the `browser`, `context`, and `page` objects, ensuring a clean slate for each test. 333 | 334 | Example: 335 | 336 | ```js 337 | beforeEach(async () => { 338 | await jestPuppeteer.resetBrowser(); 339 | }); 340 | ``` 341 | 342 | These methods simplify the setup and teardown process for tests, making it easier to work with Puppeteer in a Jest environment. 343 | 344 | ## Troubleshooting 345 | 346 | ### CI Timeout Issues 347 | 348 | In CI environments, tests may occasionally time out due to limited resources. Jest-Puppeteer allows you to control the number of workers used to run tests. Running tests serially can help avoid these timeouts: 349 | 350 | Run tests in a single process: 351 | 352 | ```bash 353 | jest --runInBand 354 | ``` 355 | 356 | Alternatively, you can limit the number of parallel workers: 357 | 358 | ```bash 359 | jest --maxWorkers=2 360 | ``` 361 | 362 | This ensures that your CI environment doesn’t get overloaded by too many concurrent processes, which can improve the reliability of your tests. 363 | 364 | ### Debugging CI Failures 365 | 366 | Sometimes, failures happen only in CI environments and not locally. In such cases, use the `debug()` method to open a browser during CI runs and inspect the page manually: 367 | 368 | ```js 369 | await jestPuppeteer.debug(); 370 | ``` 371 | 372 | To avoid test timeouts in CI, set a larger timeout during the debugging process: 373 | 374 | ```js 375 | jest.setTimeout(600000); // 10 minutes 376 | ``` 377 | 378 | ### Preventing ESLint Errors with Global Variables 379 | 380 | Jest-Puppeteer introduces global variables like `page`, `browser`, `context`, etc., which ESLint may flag as undefined. You can prevent this by adding these globals to your ESLint configuration: 381 | 382 | ```js 383 | // .eslintrc.js 384 | module.exports = { 385 | env: { 386 | jest: true, 387 | }, 388 | globals: { 389 | page: true, 390 | browser: true, 391 | context: true, 392 | puppeteerConfig: true, 393 | jestPuppeteer: true, 394 | }, 395 | }; 396 | ``` 397 | 398 | This configuration will prevent ESLint from throwing errors about undefined globals. 399 | 400 | ## Acknowledgements 401 | 402 | Special thanks to [Fumihiro Xue](https://github.com/xfumihiro) for providing an excellent [Jest Puppeteer example](https://github.com/xfumihiro/jest-puppeteer-example), which served as an inspiration for this package. 403 | -------------------------------------------------------------------------------- /conventional-github-releaser-context.json: -------------------------------------------------------------------------------- 1 | { 2 | "owner": "smooth-code", 3 | "repository": "jest-puppeteer" 4 | } 5 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | const port = process.env.TEST_SERVER_PORT 2 | ? Number(process.env.TEST_SERVER_PORT) 3 | : 4444; 4 | 5 | process.env.TEST_SERVER_PORT = port; 6 | 7 | /** 8 | * @type {import('jest-environment-puppeteer').JestPuppeteerConfig} 9 | */ 10 | const jestPuppeteerConfig = { 11 | launch: { 12 | headless: "new", 13 | args: ["--no-sandbox"], 14 | }, 15 | browserContext: process.env.INCOGNITO ? "incognito" : "default", 16 | server: { 17 | command: `cross-env PORT=${port} node server`, 18 | port, 19 | launchTimeout: 4000, 20 | usedPortAction: "kill", 21 | }, 22 | }; 23 | 24 | module.exports = jestPuppeteerConfig; 25 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: ["/packages/*"], 3 | }; 4 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "11.0.0", 3 | "$schema": "node_modules/lerna/schemas/lerna-schema.json" 4 | } -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "targetDefaults": { 3 | "build": { 4 | "dependsOn": ["^build"], 5 | "outputs": ["{projectRoot}/dist"] 6 | } 7 | }, 8 | "tasksRunnerOptions": { 9 | "default": { 10 | "runner": "nx/tasks-runners/default", 11 | "options": { 12 | "cacheableOperations": ["prebuild", "build"] 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "packages/*" 5 | ], 6 | "scripts": { 7 | "typecheck": "npx tsc && echo 'type checking was successful'", 8 | "build": "cross-env NODE_ENV=production lerna run build", 9 | "dev": "lerna watch -- lerna run build --scope=\\$LERNA_PACKAGE_NAME", 10 | "format": "prettier --write .", 11 | "lint": "prettier --check . && eslint .", 12 | "test": "jest --runInBand", 13 | "test:incognito": "cross-env INCOGNITO=true jest --runInBand", 14 | "release": "npm run build && lerna publish --conventional-commits && npx conventional-github-releaser -p angular", 15 | "release-canary": "npm run build && lerna publish --canary --dist-tag canary" 16 | }, 17 | "devDependencies": { 18 | "@swc/cli": "^0.5.2", 19 | "@swc/core": "^1.10.1", 20 | "@swc/jest": "^0.2.37", 21 | "@typescript-eslint/eslint-plugin": "^8.18.1", 22 | "@typescript-eslint/parser": "^8.18.1", 23 | "conventional-github-releaser": "^3.1.5", 24 | "cross-env": "^7.0.3", 25 | "eslint": "^8.54.0", 26 | "eslint-plugin-jest": "^28.8.3", 27 | "express": "^4.21.2", 28 | "jest": "^29.7.0", 29 | "lerna": "^8.1.9", 30 | "prettier": "^3.4.2", 31 | "puppeteer": "^23.11.1", 32 | "typescript": "^5.7.2" 33 | }, 34 | "name": "jest-puppeteer", 35 | "engines": { 36 | "node": ">=18" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/dist 3 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Smooth Code 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/README.md: -------------------------------------------------------------------------------- 1 | # expect-puppeteer 2 | 3 | [![npm version](https://img.shields.io/npm/v/expect-puppeteer.svg)](https://www.npmjs.com/package/expect-puppeteer) 4 | [![npm dm](https://img.shields.io/npm/dm/expect-puppeteer.svg)](https://www.npmjs.com/package/expect-puppeteer) 5 | [![npm dt](https://img.shields.io/npm/dt/expect-puppeteer.svg)](https://www.npmjs.com/package/expect-puppeteer) 6 | 7 | Assertion library for Puppeteer. 8 | 9 | ``` 10 | npm install expect-puppeteer 11 | ``` 12 | 13 | ## Usage 14 | 15 | Modify your Jest configuration: 16 | 17 | ```json 18 | { 19 | "setupFilesAfterEnv": ["expect-puppeteer"] 20 | } 21 | ``` 22 | 23 | ## Why do I need it 24 | 25 | Writing integration test is very hard, especially when you are testing a Single Page Applications. Data are loaded asynchronously and it is difficult to know exactly when an element will be displayed in the page. 26 | 27 | [Puppeteer API](https://pptr.dev/api) is great, but it is low level and not designed for integration testing. 28 | 29 | This API is designed for integration testing: 30 | 31 | - It will wait for element before running an action 32 | - It adds additional feature like matching an element using text 33 | 34 | **Example** 35 | 36 | ```js 37 | // Does not work if button is not in page 38 | await page.click("button"); 39 | 40 | // Will try for 500ms to click on "button" 41 | await page.toClick("button"); 42 | 43 | // Will click the first button with a "My button" text inside 44 | await page.toClick("button", { text: "My button" }); 45 | ``` 46 | 47 | The first element to match will be selected 48 | 49 | **Example** 50 | 51 | ```html 52 |
53 |
some text
54 |
55 | ``` 56 | 57 | ```js 58 | // Will match outer div 59 | await expect(page).toMatchElement("div", { text: "some text" }); 60 | 61 | // Will match inner div 62 | await expect(page).toMatchElement("div.inner", { text: "some text" }); 63 | ``` 64 | 65 | ## API 66 | 67 | ##### Table of Contents 68 | 69 | 70 | 71 | - [toClick](#user-content-toClick) 72 | - [toDisplayDialog](#user-content-toDisplayDialog) 73 | - [toFill](#user-content-toFill) 74 | - [toFillForm](#user-content-toFillForm) 75 | - [toMatchTextContent](#user-content-toMatchTextContent) 76 | - [toMatchElement](#user-content-toMatchElement) 77 | - [toSelect](#user-content-toSelect) 78 | - [toUploadFile](#user-content-toUploadFile) 79 | 80 | ### expect(instance).toClick(selector[, options]) 81 | 82 | Expect an element to be in the page or element, then click on it. 83 | 84 | - `instance` <[Page]|[Frame]|[ElementHandle]> Context 85 | - `selector` <[string]|[MatchSelector](#MatchSelector)> A [selector] or a [MatchSelector](#MatchSelector) to click on. 86 | - `options` <[Object]> Optional parameters 87 | - `button` <"left"|"right"|"middle"> Defaults to `left`. 88 | - `count` <[number]> defaults to 1. See [UIEvent.detail]. 89 | - `delay` <[number]> Time to wait between `mousedown` and `mouseup` in milliseconds. Defaults to 0. 90 | - `text` <[string]|[RegExp]> A text or a RegExp to match in element `textContent`. 91 | 92 | ```js 93 | await expect(page).toClick("button", { text: "Home" }); 94 | await expect(page).toClick({ type: "xpath", value: "\\a" }, { text: "Click" }); 95 | ``` 96 | 97 | ### expect(page).toDisplayDialog(block) 98 | 99 | Expect block function to trigger a dialog and returns it. 100 | 101 | - `page` <[Page]> Context 102 | - `block` <[function]> A [function] that should trigger a dialog 103 | 104 | ```js 105 | const dialog = await expect(page).toDisplayDialog(async () => { 106 | await expect(page).toClick("button", { text: "Show dialog" }); 107 | }); 108 | ``` 109 | 110 | ### expect(instance).toFill(selector, value[, options]) 111 | 112 | Expect a control to be in the page or element, then fill it with text. 113 | 114 | - `instance` <[Page]|[Frame]|[ElementHandle]> Context 115 | - `selector` <[string]|[MatchSelector](#MatchSelector)> A [selector] or a [MatchSelector](#MatchSelector) to match field 116 | - `value` <[string]> Value to fill 117 | - `options` <[Object]> Optional parameters 118 | - `delay` <[number]> delay to pass to [the puppeteer `element.type` API](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#elementhandletypetext-options) 119 | 120 | ```js 121 | await expect(page).toFill('input[name="firstName"]', "James"); 122 | ``` 123 | 124 | ### expect(instance).toFillForm(selector, values[, options]) 125 | 126 | Expect a form to be in the page or element, then fill its controls. 127 | 128 | - `instance` <[Page]|[Frame]|[ElementHandle]> Context 129 | - `selector` <[string]|[MatchSelector](#MatchSelector)> A [selector] or a [MatchSelector](#MatchSelector) to match form 130 | - `values` <[Object]> Values to fill 131 | - `options` <[Object]> Optional parameters 132 | - `delay` <[number]> delay to pass to [the puppeteer `element.type` API](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#elementhandletypetext-options) 133 | 134 | ```js 135 | await expect(page).toFillForm('form[name="myForm"]', { 136 | firstName: "James", 137 | lastName: "Bond", 138 | }); 139 | ``` 140 | 141 | ### expect(instance).toMatchTextContent(matcher[, options]) 142 | 143 | Expect a text or a string RegExp to be present in the page or element. 144 | 145 | - `instance` <[Page]|[Frame]|[ElementHandle]> Context 146 | - `matcher` <[string]|[RegExp]> A text or a RegExp to match in page 147 | - `options` <[Object]> Optional parameters 148 | - `polling` <[string]|[number]> An interval at which the `pageFunction` is executed, defaults to `raf`. If `polling` is a number, then it is treated as an interval in milliseconds at which the function would be executed. If `polling` is a string, then it can be one of the following values: 149 | - `raf` - to constantly execute `pageFunction` in `requestAnimationFrame` callback. This is the tightest polling mode which is suitable to observe styling changes. 150 | - `mutation` - to execute `pageFunction` on every DOM mutation. 151 | - `timeout` <[number]> maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can be changed by using the [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) method. 152 | - `traverseShadowRoots`<[boolean]> Whether shadow roots should be traversed to find a match. 153 | 154 | ```js 155 | // Matching using text 156 | await expect(page).toMatchTextContent("Lorem ipsum"); 157 | // Matching using RegExp 158 | await expect(page).toMatchTextContent(/lo.*/); 159 | ``` 160 | 161 | ### expect(instance).toMatchElement(selector[, options]) 162 | 163 | Expect an element be present in the page or element. 164 | 165 | - `instance` <[Page]|[Frame]|[ElementHandle]> Context 166 | - `selector` <[string]|[MatchSelector](#MatchSelector)> A [selector] or a [MatchSelector](#MatchSelector) to match element 167 | - `options` <[Object]> Optional parameters 168 | - `polling` <[string]|[number]> An interval at which the `pageFunction` is executed, defaults to `raf`. If `polling` is a number, then it is treated as an interval in milliseconds at which the function would be executed. If `polling` is a string, then it can be one of the following values: 169 | - `raf` - to constantly execute `pageFunction` in `requestAnimationFrame` callback. This is the tightest polling mode which is suitable to observe styling changes. 170 | - `mutation` - to execute `pageFunction` on every DOM mutation. 171 | - `timeout` <[number]> maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can be changed by using the [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) method. 172 | - `text` <[string]|[RegExp]> A text or a RegExp to match in element `textContent`. 173 | - `visible` <[boolean]> wait for element to be present in DOM and to be visible, i.e. to not have `display: none` or `visibility: hidden` CSS properties. Defaults to `false`. 174 | 175 | ```js 176 | // Select a row containing a text 177 | const row = await expect(page).toMatchElement("tr", { text: "My row" }); 178 | // Click on the third column link 179 | await expect(row).toClick("td:nth-child(3) a"); 180 | ``` 181 | 182 | ### expect(instance).toSelect(selector, valueOrText) 183 | 184 | Expect a select control to be present in the page or element, then select the specified option. 185 | 186 | - `instance` <[Page]|[Frame]|[ElementHandle]> Context 187 | - `selector` <[string]|[MatchSelector](#MatchSelector)> A [selector] or a [MatchSelector](#MatchSelector) to match select [element] 188 | - `valueOrText` <[string]> Value or text matching option 189 | 190 | ```js 191 | await expect(page).toSelect('select[name="choices"]', "Choice 1"); 192 | ``` 193 | 194 | ### expect(instance).toUploadFile(selector, filePath) 195 | 196 | Expect a input file control to be present in the page or element, then fill it with a local file. 197 | 198 | - `instance` <[Page]|[Frame]|[ElementHandle]> Context 199 | - `selector` <[string]|[MatchSelector](#MatchSelector)> A [selector] or a [MatchSelector](#MatchSelector) to match input [element] 200 | - `filePath` <[string]|[Array]<[string]>> A file path or array of file paths 201 | 202 | ```js 203 | import { join } from "node:path"; 204 | 205 | await expect(page).toUploadFile( 206 | 'input[type="file"]', 207 | join(__dirname, "file.txt"), 208 | ); 209 | ``` 210 | 211 | ### Match Selector 212 | 213 | An object used as parameter in order to select an element. 214 | 215 | - `type` <"xpath"|"css"> The type of the selector 216 | - `value` <[string]> The value of the selector 217 | 218 | ```js 219 | {type:'css', value:'form[name="myForm"]'} 220 | {type:'xpath', value:'.\\a'} 221 | ``` 222 | 223 | ## Configure default options 224 | 225 | To configure default options like `timeout`, `expect-puppeteer` exposes two methods: `getDefaultOptions` and `setDefaultOptions`. You can find available options in [Puppeteer `page.waitForFunction` documentation](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagewaitforfunctionpagefunction-options-args). Default options are set to: `{ timeout: 500 }`. 226 | 227 | ```js 228 | import { setDefaultOptions } from "expect-puppeteer"; 229 | 230 | setDefaultOptions({ timeout: 1000 }); 231 | ``` 232 | 233 | [array]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array "Array" 234 | [boolean]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type "Boolean" 235 | [function]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function "Function" 236 | [number]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type "Number" 237 | [object]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object "Object" 238 | [promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise "Promise" 239 | [regexp]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp "RegExp" 240 | [string]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type "String" 241 | [error]: https://nodejs.org/api/errors.html#errors_class_error "Error" 242 | [element]: https://developer.mozilla.org/en-US/docs/Web/API/element "Element" 243 | [map]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map "Map" 244 | [selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors "selector" 245 | [page]: https://pptr.dev/api/puppeteer.page "Page" 246 | [frame]: https://pptr.dev/api/puppeteer.frame "Frame" 247 | [elementhandle]: https://pptr.dev/api/puppeteer.elementhandle/ "ElementHandle" 248 | [uievent.detail]: https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail 249 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/__fixtures__/file.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/argos-ci/jest-puppeteer/ddb3fb9d80b2d38d27b327c0d857280edf877f3e/packages/expect-puppeteer/__fixtures__/file.txt -------------------------------------------------------------------------------- /packages/expect-puppeteer/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "/../jest-puppeteer", 3 | transform: { 4 | "^.+\\.(t|j)sx?$": ["@swc/jest"], 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expect-puppeteer", 3 | "description": "Assertion toolkit for Puppeteer.", 4 | "version": "11.0.0", 5 | "type": "commonjs", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/index.d.ts", 11 | "default": "./dist/index.js" 12 | }, 13 | "./setup": "./dist/setup.js", 14 | "./package.json": "./package.json" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/argos-ci/jest-puppeteer.git", 19 | "directory": "packages/expect-puppeteer" 20 | }, 21 | "license": "MIT", 22 | "homepage": "https://github.com/argos-ci/jest-puppeteer/tree/main/packages/expect-puppeteer#readme", 23 | "bugs": { 24 | "url": "https://github.com/argos-ci/jest-puppeteer/issues" 25 | }, 26 | "engines": { 27 | "node": ">=18" 28 | }, 29 | "keywords": [ 30 | "jest", 31 | "puppeteer", 32 | "jest-puppeteer", 33 | "chromeless", 34 | "chrome-headless", 35 | "expect", 36 | "assert", 37 | "should", 38 | "assertion" 39 | ], 40 | "publishConfig": { 41 | "access": "public" 42 | }, 43 | "scripts": { 44 | "prebuild": "rm -rf dist", 45 | "build": "rollup -c" 46 | }, 47 | "devDependencies": { 48 | "puppeteer": "^23.11.1", 49 | "rollup": "^4.29.1", 50 | "rollup-plugin-dts": "^6.1.1", 51 | "rollup-plugin-swc3": "^0.12.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { swc, defineRollupSwcOption } from "rollup-plugin-swc3"; 2 | import dts from "rollup-plugin-dts"; 3 | import * as url from "node:url"; 4 | 5 | const bundle = (config) => ({ 6 | input: "src/index.ts", 7 | external: (id) => { 8 | return !/^[./]/.test(id) || id === "./index.js"; 9 | }, 10 | ...config, 11 | }); 12 | 13 | const swcPlugin = swc( 14 | defineRollupSwcOption({ 15 | jsc: { 16 | baseUrl: url.fileURLToPath(new URL(".", import.meta.url)), 17 | parser: { 18 | syntax: "typescript", 19 | }, 20 | target: "es2022", 21 | externalHelpers: false, 22 | }, 23 | }), 24 | ); 25 | 26 | export default [ 27 | bundle({ 28 | output: { 29 | file: "dist/index.js", 30 | format: "cjs", 31 | }, 32 | plugins: [swcPlugin], 33 | }), 34 | bundle({ 35 | plugins: [dts()], 36 | output: { 37 | file: "dist/index.d.ts", 38 | format: "es", 39 | }, 40 | }), 41 | ]; 42 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`expect-puppeteer should works with \`addSnapshotSerializer\` 1`] = `hello`; 4 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/globals.test.ts: -------------------------------------------------------------------------------- 1 | // import jest globals 2 | import { xdescribe, beforeAll, it, expect } from "@jest/globals"; 3 | 4 | // import jest-puppeteer globals 5 | import "jest-puppeteer"; 6 | import "expect-puppeteer"; 7 | 8 | // test explicit imports from @jest/globals (incompatible with matchers implementation) 9 | xdescribe("Google", (): void => { 10 | beforeAll(async (): Promise => { 11 | await page.goto("https://google.com"); 12 | }); 13 | 14 | it('should display "google" text on page', async (): Promise => { 15 | await expect(page).not.toMatchTextContent("google", {}); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { getDefaultOptions, setDefaultOptions } from "expect-puppeteer"; 2 | 3 | // import globals 4 | import "jest-puppeteer"; 5 | 6 | expect.addSnapshotSerializer({ 7 | print: () => "hello", 8 | test: () => true, 9 | serialize: () => "hello", 10 | }); 11 | 12 | describe("expect-puppeteer", () => { 13 | beforeEach(async () => { 14 | await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`); 15 | }); 16 | 17 | it("should work with original Jest matchers", async () => { 18 | expect(page).toBeDefined(); 19 | expect(page).not.toBe(null); 20 | 21 | const main = await page.$("main"); 22 | expect(main).toBeDefined(); 23 | expect(main).not.toBe(null); 24 | 25 | expect(200).toBe(200); 26 | }); 27 | 28 | it("should works with `addSnapshotSerializer`", () => { 29 | expect({ hello: "world" }).toMatchSnapshot(); 30 | }); 31 | 32 | it("should get and set default options", () => { 33 | expect(getDefaultOptions()).toEqual({ timeout: 500 }); 34 | setDefaultOptions({ timeout: 200 }); 35 | expect(getDefaultOptions()).toEqual({ timeout: 200 }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/index.ts: -------------------------------------------------------------------------------- 1 | // import modules 2 | import { notToMatchTextContent } from "./matchers/notToMatchTextContent"; 3 | import { notToMatchElement } from "./matchers/notToMatchElement"; 4 | import { toClick } from "./matchers/toClick"; 5 | import { toDisplayDialog } from "./matchers/toDisplayDialog"; 6 | import { toFill } from "./matchers/toFill"; 7 | import { toFillForm } from "./matchers/toFillForm"; 8 | import { toMatchTextContent } from "./matchers/toMatchTextContent"; 9 | import { toMatchElement } from "./matchers/toMatchElement"; 10 | import { toSelect } from "./matchers/toSelect"; 11 | import { toUploadFile } from "./matchers/toUploadFile"; 12 | import { 13 | checkIsPuppeteerInstance, 14 | checkIsElementHandle, 15 | checkIsFrame, 16 | checkIsPage, 17 | } from "./utils"; 18 | 19 | // import interfaces and types 20 | import type { JestExpect } from "@jest/expect"; 21 | import type { ElementHandle, Frame, Page } from "puppeteer"; 22 | import type { PuppeteerInstance } from "./utils"; 23 | 24 | // reexport 25 | export { setDefaultOptions, getDefaultOptions } from "./options"; 26 | 27 | // --------------------------- 28 | 29 | // declare native matcher function signature 30 | type PuppeteerMatcher = (page: T, ...args: unknown[]) => Promise; 31 | 32 | // return intersection type from union type 33 | type Intersection = (T extends unknown ? (k: T) => void : never) extends ( 34 | k: infer R, 35 | ) => void 36 | ? R 37 | : never; 38 | 39 | // declare wrapped matcher function signature 40 | type Wrapper = T extends ( 41 | page: Intersection, 42 | ...args: infer A 43 | ) => infer R 44 | ? (...args: A) => R 45 | : never; 46 | 47 | // declare common matchers list 48 | type InstanceMatchers = T extends PuppeteerInstance 49 | ? { 50 | // common 51 | toClick: Wrapper; 52 | toFill: Wrapper; 53 | toFillForm: Wrapper; 54 | toMatchTextContent: Wrapper; 55 | toMatchElement: Wrapper; 56 | toSelect: Wrapper; 57 | toUploadFile: Wrapper; 58 | // inverse matchers 59 | not: { 60 | toMatchTextContent: Wrapper; 61 | toMatchElement: Wrapper; 62 | }; 63 | } 64 | : never; 65 | 66 | // declare page matchers list 67 | interface PageMatchers extends InstanceMatchers { 68 | // instance specific 69 | toDisplayDialog: Wrapper; 70 | // inverse matchers 71 | not: InstanceMatchers[`not`] & {}; 72 | } 73 | 74 | // declare frame matchers list 75 | interface FrameMatchers extends InstanceMatchers { 76 | // inverse matchers 77 | not: InstanceMatchers[`not`] & {}; 78 | } 79 | 80 | // declare element matchers list 81 | interface ElementHandleMatchers 82 | extends InstanceMatchers> { 83 | // inverse matchers 84 | not: InstanceMatchers>[`not`] & {}; 85 | } 86 | 87 | // declare matchers per instance type 88 | type PMatchersPerType = T extends Page 89 | ? PageMatchers 90 | : T extends Frame 91 | ? FrameMatchers 92 | : T extends ElementHandle 93 | ? ElementHandleMatchers 94 | : never; 95 | 96 | // constraint current expect to support puppeteer matchers 97 | export interface PuppeteerExpect { 98 | (actual: T): PMatchersPerType; 99 | } 100 | 101 | // global object signature 102 | type GlobalWithExpect = typeof globalThis & { expect: PuppeteerExpect }; 103 | 104 | // --------------------------- 105 | 106 | // not possible to use PMatchersPerType directly ... 107 | interface PuppeteerMatchers { 108 | // common 109 | toClick: T extends PuppeteerInstance ? Wrapper : never; 110 | toFill: T extends PuppeteerInstance ? Wrapper : never; 111 | toFillForm: T extends PuppeteerInstance ? Wrapper : never; 112 | toMatchTextContent: T extends PuppeteerInstance 113 | ? Wrapper 114 | : never; 115 | toMatchElement: T extends PuppeteerInstance 116 | ? Wrapper 117 | : never; 118 | toSelect: T extends PuppeteerInstance ? Wrapper : never; 119 | toUploadFile: T extends PuppeteerInstance 120 | ? Wrapper 121 | : never; 122 | // page 123 | toDisplayDialog: T extends Page ? Wrapper : never; 124 | // inverse matchers 125 | not: { 126 | toMatchTextContent: T extends PuppeteerInstance 127 | ? Wrapper 128 | : never; 129 | toMatchElement: T extends PuppeteerInstance 130 | ? Wrapper 131 | : never; 132 | }; 133 | } 134 | 135 | // support for @types/jest 136 | declare global { 137 | // eslint-disable-next-line @typescript-eslint/no-namespace 138 | namespace jest { 139 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-unused-vars 140 | interface Matchers extends PuppeteerMatchers {} 141 | } 142 | } 143 | 144 | // --------------------------- 145 | // @ts-expect-error global node object w/ initial jest expect prop attached 146 | const jestExpect = global.expect as JestExpect; 147 | 148 | // --------------------------- 149 | // wrapper executing the matcher and capturing the stack trace on error before rethrowing 150 | const wrapMatcher = ( 151 | matcher: PuppeteerMatcher, 152 | instance: T, 153 | ) => 154 | async function throwingMatcher(...args: unknown[]): Promise { 155 | // update the assertions counter 156 | jestExpect.getState().assertionCalls += 1; 157 | try { 158 | // run async matcher 159 | const result = await matcher(instance, ...args); 160 | // resolve 161 | return result; 162 | } catch (err: unknown) { 163 | if (err instanceof Error) Error.captureStackTrace(err, throwingMatcher); 164 | // reject 165 | throw err; 166 | } 167 | } as Wrapper>; 168 | 169 | // --------------------------- 170 | // create the generic expect object and bind wrapped matchers to it 171 | const puppeteerExpect = (instance: T) => { 172 | // read instance type 173 | const [isPage, isFrame, isHandle] = [ 174 | checkIsPage(instance), 175 | checkIsFrame(instance), 176 | checkIsElementHandle(instance), 177 | ]; 178 | 179 | if (!isPage && !isFrame && !isHandle) 180 | throw new Error(`${instance.constructor.name} is not supported`); 181 | 182 | // retrieve matchers 183 | const expectation = { 184 | // common 185 | toClick: wrapMatcher(toClick as PuppeteerMatcher, instance), 186 | toFill: wrapMatcher(toFill as PuppeteerMatcher, instance), 187 | toFillForm: wrapMatcher(toFillForm as PuppeteerMatcher, instance), 188 | toMatchTextContent: wrapMatcher( 189 | toMatchTextContent as PuppeteerMatcher, 190 | instance, 191 | ), 192 | toMatchElement: wrapMatcher( 193 | toMatchElement as PuppeteerMatcher, 194 | instance, 195 | ), 196 | toSelect: wrapMatcher(toSelect as PuppeteerMatcher, instance), 197 | toUploadFile: wrapMatcher(toUploadFile as PuppeteerMatcher, instance), 198 | // page 199 | toDisplayDialog: isPage 200 | ? wrapMatcher(toDisplayDialog as PuppeteerMatcher, instance) 201 | : undefined, 202 | // inverse matchers 203 | not: { 204 | toMatchTextContent: wrapMatcher( 205 | notToMatchTextContent as PuppeteerMatcher, 206 | instance, 207 | ), 208 | toMatchElement: wrapMatcher( 209 | notToMatchElement as PuppeteerMatcher, 210 | instance, 211 | ), 212 | }, 213 | }; 214 | 215 | return expectation as unknown as PMatchersPerType; 216 | }; 217 | 218 | // --------------------------- 219 | // merge puppeteer matchers w/ jest matchers and return a new object 220 | const expectPuppeteer = ((actual: T) => { 221 | // puppeteer 222 | if (checkIsPuppeteerInstance(actual)) { 223 | const matchers = puppeteerExpect(actual); 224 | const jestMatchers = jestExpect(actual); 225 | return { 226 | ...jestMatchers, 227 | ...matchers, 228 | not: { 229 | ...jestMatchers.not, 230 | ...matchers.not, 231 | }, 232 | }; 233 | } 234 | 235 | // not puppeteer (fall back to jest defaults, puppeteer matchers not available) 236 | return jestExpect(actual); 237 | }) as PuppeteerExpect & JestExpect; 238 | 239 | Object.keys(jestExpect).forEach((prop) => { 240 | // @ts-expect-error add jest expect properties to expect-puppeteer implementation 241 | expectPuppeteer[prop] = jestExpect[prop] as unknown; 242 | }); 243 | 244 | export { expectPuppeteer as expect }; 245 | 246 | // replace jest expect by expect-puppeteer ... 247 | if (typeof (global as GlobalWithExpect).expect !== `undefined`) 248 | (global as GlobalWithExpect).expect = expectPuppeteer; 249 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/getElementFactory.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getContext, 3 | PuppeteerInstance, 4 | Selector, 5 | serializeSearchExpression, 6 | evaluateParseSearchExpression, 7 | SerializedSearchExpression, 8 | } from "../utils"; 9 | 10 | export type GetElementOptions = { 11 | text?: string | RegExp; 12 | visible?: boolean; 13 | }; 14 | 15 | export async function getElementFactory( 16 | instance: PuppeteerInstance, 17 | selector: Selector, 18 | options: GetElementOptions, 19 | ) { 20 | const { text: searchExpr, visible = false } = options; 21 | 22 | const ctx = await getContext(instance, () => document); 23 | 24 | const { text, regexp } = serializeSearchExpression(searchExpr); 25 | 26 | const parseSearchExpressionHandle = await evaluateParseSearchExpression( 27 | ctx.page, 28 | ); 29 | 30 | const getElementArgs = [ 31 | ctx.handle, 32 | selector, 33 | text, 34 | regexp, 35 | visible, 36 | parseSearchExpressionHandle, 37 | ] as const; 38 | 39 | const getElement = ( 40 | handle: Element | Document, 41 | selector: Selector, 42 | text: string | null, 43 | regexp: string | null, 44 | visible: boolean, 45 | parseSearchExpression: ( 46 | expr: SerializedSearchExpression, 47 | ) => ((value: string) => boolean) | null, 48 | type: "element" | "positive" | "negative", 49 | ) => { 50 | const hasVisibleBoundingBox = (element: Element): boolean => { 51 | const rect = element.getBoundingClientRect(); 52 | return !!(rect.top || rect.bottom || rect.width || rect.height); 53 | }; 54 | 55 | const checkNodeIsElement = (node: Node): node is Element => { 56 | return node.nodeType === Node.ELEMENT_NODE; 57 | }; 58 | 59 | const checkIsElementVisible = (element: Element) => { 60 | const style = window.getComputedStyle(element); 61 | return style?.visibility !== "hidden" && hasVisibleBoundingBox(element); 62 | }; 63 | 64 | let elements: Element[] = []; 65 | switch (selector.type) { 66 | case "xpath": { 67 | const results = document.evaluate(selector.value, handle); 68 | let node = results.iterateNext(); 69 | while (node) { 70 | if (checkNodeIsElement(node)) { 71 | elements.push(node); 72 | } 73 | node = results.iterateNext(); 74 | } 75 | break; 76 | } 77 | case "css": 78 | elements = Array.from(handle.querySelectorAll(selector.value)); 79 | break; 80 | default: 81 | throw new Error(`${selector.type} is not implemented`); 82 | } 83 | 84 | elements = visible ? elements.filter(checkIsElementVisible) : elements; 85 | 86 | const matcher = parseSearchExpression({ text, regexp }); 87 | const element = matcher 88 | ? elements.find(({ textContent }) => textContent && matcher(textContent)) 89 | : elements[0]; 90 | 91 | switch (type) { 92 | case "element": 93 | return element; 94 | case "positive": 95 | return !!element; 96 | case "negative": 97 | return !element; 98 | default: 99 | throw new Error(`Unknown type: ${type}`); 100 | } 101 | }; 102 | 103 | return [getElement, getElementArgs, ctx] as const; 104 | } 105 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/matchTextContent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getContext, 3 | serializeSearchExpression, 4 | PuppeteerInstance, 5 | SearchExpression, 6 | evaluateParseSearchExpression, 7 | } from "../utils"; 8 | import { defaultOptions, Options } from "../options"; 9 | 10 | export type MatchTextContentOptions = Options & { 11 | traverseShadowRoots?: boolean; 12 | }; 13 | 14 | export async function matchTextContent( 15 | instance: PuppeteerInstance, 16 | matcher: SearchExpression, 17 | options: MatchTextContentOptions, 18 | type: "positive" | "negative", 19 | ) { 20 | const { traverseShadowRoots = false, ...otherOptions } = options; 21 | const frameOptions = defaultOptions(otherOptions); 22 | 23 | const ctx = await getContext(instance, () => document.body); 24 | 25 | const { text, regexp } = serializeSearchExpression(matcher); 26 | 27 | const parseSearchExpressionHandle = await evaluateParseSearchExpression( 28 | ctx.page, 29 | ); 30 | 31 | await ctx.page.waitForFunction( 32 | ( 33 | handle, 34 | text, 35 | regexp, 36 | traverseShadowRoots, 37 | parseSearchExpression, 38 | type, 39 | ) => { 40 | const checkNodeIsElement = (node: Node): node is Element => { 41 | return node.nodeType === Node.ELEMENT_NODE; 42 | }; 43 | 44 | const checkNodeIsText = (node: Node): node is Element => { 45 | return node.nodeType === Node.TEXT_NODE; 46 | }; 47 | 48 | const checkIsHtmlSlotElement = (node: Node): node is HTMLSlotElement => { 49 | return node.nodeType === Node.ELEMENT_NODE && node.nodeName === "SLOT"; 50 | }; 51 | 52 | function getShadowTextContent(node: Node) { 53 | const walker = document.createTreeWalker( 54 | node, 55 | NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, 56 | ); 57 | let result = ""; 58 | let currentNode = walker.nextNode(); 59 | while (currentNode) { 60 | if (checkNodeIsText(currentNode)) { 61 | result += currentNode.textContent; 62 | } else if (checkNodeIsElement(currentNode)) { 63 | if (currentNode.assignedSlot) { 64 | // Skip everything within this subtree, since it's assigned to a slot in the shadow DOM. 65 | const nodeWithAssignedSlot = currentNode; 66 | while ( 67 | currentNode === nodeWithAssignedSlot || 68 | nodeWithAssignedSlot.contains(currentNode) 69 | ) { 70 | currentNode = walker.nextNode(); 71 | } 72 | // eslint-disable-next-line no-continue 73 | continue; 74 | } else if (currentNode.shadowRoot) { 75 | result += getShadowTextContent(currentNode.shadowRoot); 76 | } else if (checkIsHtmlSlotElement(currentNode)) { 77 | const assignedNodes = currentNode.assignedNodes(); 78 | assignedNodes.forEach((node) => { 79 | result += getShadowTextContent(node); 80 | }); 81 | } 82 | } 83 | currentNode = walker.nextNode(); 84 | } 85 | return result; 86 | } 87 | 88 | if (!handle) return false; 89 | 90 | const textContent = traverseShadowRoots 91 | ? getShadowTextContent(handle) 92 | : handle.textContent; 93 | 94 | const matcher = parseSearchExpression({ text, regexp }); 95 | if (!matcher) { 96 | throw new Error(`Invalid ${type} matcher: "${text}" or "${regexp}".`); 97 | } 98 | switch (type) { 99 | case "positive": 100 | return Boolean(textContent && matcher(textContent)); 101 | case "negative": 102 | return Boolean(!textContent || !matcher(textContent)); 103 | default: 104 | throw new Error(`Invalid type: "${type}".`); 105 | } 106 | }, 107 | frameOptions, 108 | ctx.handle, 109 | text, 110 | regexp, 111 | traverseShadowRoots, 112 | parseSearchExpressionHandle, 113 | type, 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/notToMatchElement.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { Frame, Page, TimeoutError } from "puppeteer"; 3 | import { setupPage } from "./test-util"; 4 | 5 | // import globals 6 | import "jest-puppeteer"; 7 | import "expect-puppeteer"; 8 | 9 | describe("not.toMatchElement", () => { 10 | beforeEach(async () => { 11 | await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`); 12 | }); 13 | 14 | describe.each(["Page", "Frame"])("%s", (instanceType) => { 15 | let instance: Page | Frame; 16 | setupPage(instanceType, ({ currentPage }) => { 17 | instance = currentPage; 18 | }); 19 | it("should not match using selector", async () => { 20 | await expect(instance).not.toMatchElement("wtf"); 21 | }); 22 | 23 | it("should match using text", async () => { 24 | await expect(instance).not.toMatchElement("a", { text: "Nothing here" }); 25 | }); 26 | 27 | it("should return an error if element is not found", async () => { 28 | expect.assertions(4); 29 | 30 | try { 31 | await expect(instance).not.toMatchElement("a", { text: "Page 2" }); 32 | } catch (error: unknown) { 33 | const e = error as TimeoutError; 34 | expect(e.message).toMatch('Element a (text: "Page 2") found'); 35 | expect(e.message).toMatch("Waiting failed: 500ms exceeded"); 36 | expect(e.stack).toMatch(resolve(__filename)); 37 | } 38 | }); 39 | }); 40 | 41 | describe("ElementHandle", () => { 42 | it("should not match using selector", async () => { 43 | const main = await page.$("main"); 44 | await expect(main).not.toMatchElement("main"); 45 | }); 46 | 47 | it("should match using text", async () => { 48 | const main = await page.$("main"); 49 | await expect(main).not.toMatchElement("div", { text: "Nothing here" }); 50 | }); 51 | 52 | it("should return an error if element is not found", async () => { 53 | const main = await page.$("main"); 54 | expect.assertions(4); 55 | 56 | try { 57 | await expect(main).not.toMatchElement("div", { text: "main" }); 58 | } catch (error: unknown) { 59 | const e = error as TimeoutError; 60 | expect(e.message).toMatch('Element div (text: "main") found'); 61 | expect(e.message).toMatch("Waiting failed: 500ms exceeded"); 62 | expect(e.stack).toMatch(resolve(__filename)); 63 | } 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/notToMatchElement.ts: -------------------------------------------------------------------------------- 1 | // import { 2 | // getContext, 3 | // enhanceError, 4 | // PuppeteerInstance, 5 | // Selector, 6 | // } from "../utils"; 7 | // import { defaultOptions, Options } from "../options"; 8 | 9 | import { defaultOptions, Options } from "../options"; 10 | import { 11 | enhanceError, 12 | getSelectorMessage, 13 | PuppeteerInstance, 14 | resolveSelector, 15 | Selector, 16 | } from "../utils"; 17 | import { getElementFactory, GetElementOptions } from "./getElementFactory"; 18 | 19 | export type NotToMatchElementOptions = GetElementOptions & Options; 20 | 21 | export async function notToMatchElement( 22 | instance: PuppeteerInstance, 23 | selector: Selector | string, 24 | options: NotToMatchElementOptions = {}, 25 | ) { 26 | const { text, visible, ...otherOptions } = options; 27 | const frameOptions = defaultOptions(otherOptions); 28 | const rSelector = resolveSelector(selector); 29 | const [getElement, getElementArgs, ctx] = await getElementFactory( 30 | instance, 31 | rSelector, 32 | { text, visible }, 33 | ); 34 | 35 | try { 36 | await ctx.page.waitForFunction( 37 | getElement, 38 | frameOptions, 39 | ...getElementArgs, 40 | "negative" as const, 41 | ); 42 | } catch (error: any) { 43 | throw enhanceError(error, `${getSelectorMessage(rSelector, text)} found`); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/notToMatchTextContent.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { Frame, Page, TimeoutError } from "puppeteer"; 3 | import { setupPage } from "./test-util"; 4 | 5 | // import globals 6 | import "jest-puppeteer"; 7 | import "expect-puppeteer"; 8 | 9 | describe("not.toMatchTextContent", () => { 10 | beforeEach(async () => { 11 | await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`); 12 | }); 13 | 14 | describe.each(["Page", "Frame", "ShadowPage", "ShadowFrame"])( 15 | "%s", 16 | (instanceType) => { 17 | let instance: Page | Frame; 18 | setupPage(instanceType, ({ currentPage }) => { 19 | instance = currentPage; 20 | }); 21 | 22 | const options = ["ShadowPage", "ShadowFrame"].includes(instanceType) 23 | ? { traverseShadowRoots: true } 24 | : {}; 25 | 26 | it("should be ok if text is not in the page", async () => { 27 | await expect(instance).not.toMatchTextContent("Nop!", options); 28 | }); 29 | 30 | it("should return an error if text is in the page", async () => { 31 | expect.assertions(4); 32 | 33 | try { 34 | await expect(instance).not.toMatchTextContent("home", options); 35 | } catch (error: unknown) { 36 | const e = error as TimeoutError; 37 | expect(e.message).toMatch('Text found "home"'); 38 | expect(e.message).toMatch("Waiting failed: 500ms exceeded"); 39 | expect(e.stack).toMatch(resolve(__filename)); 40 | } 41 | }); 42 | }, 43 | ); 44 | 45 | describe("ElementHandle", () => { 46 | it("should be ok if text is in the page", async () => { 47 | const dialogBtn = await page.$("#dialog-btn"); 48 | await expect(dialogBtn).not.toMatchTextContent("Nop"); 49 | }); 50 | 51 | it("should return an error if text is not in the page", async () => { 52 | expect.assertions(4); 53 | const dialogBtn = await page.$("#dialog-btn"); 54 | 55 | try { 56 | await expect(dialogBtn).not.toMatchTextContent("Open dialog"); 57 | } catch (error: unknown) { 58 | const e = error as TimeoutError; 59 | expect(e.message).toMatch('Text found "Open dialog"'); 60 | expect(e.message).toMatch("Waiting failed: 500ms exceeded"); 61 | expect(e.stack).toMatch(resolve(__filename)); 62 | } 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/notToMatchTextContent.ts: -------------------------------------------------------------------------------- 1 | import { enhanceError, PuppeteerInstance, SearchExpression } from "../utils"; 2 | import { matchTextContent, MatchTextContentOptions } from "./matchTextContent"; 3 | 4 | export type NotToMatchOptions = MatchTextContentOptions; 5 | 6 | export async function notToMatchTextContent( 7 | instance: PuppeteerInstance, 8 | matcher: SearchExpression, 9 | options: NotToMatchOptions = {}, 10 | ) { 11 | try { 12 | await matchTextContent(instance, matcher, options, "negative"); 13 | } catch (error: any) { 14 | throw enhanceError(error, `Text found "${matcher}"`); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/test-util.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import type { Page, Frame } from "puppeteer"; 3 | 4 | function waitForFrame(page: Page) { 5 | return new Promise((resolve) => { 6 | function checkFrame() { 7 | const frame = page.frames().find((f) => f.parentFrame() !== null); 8 | if (frame) resolve(frame); 9 | else page.once(`frameattached`, checkFrame); 10 | } 11 | checkFrame(); 12 | }); 13 | } 14 | 15 | async function goToPage( 16 | page: Page, 17 | route: string, 18 | isFrame: boolean, 19 | cb: (arg0: { currentPage: Page | Frame }) => void, 20 | ) { 21 | let currentPage: Page | Frame = page; 22 | await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}/${route}`); 23 | if (isFrame) { 24 | currentPage = await waitForFrame(page); 25 | } 26 | cb({ currentPage }); 27 | } 28 | 29 | export const setupPage = ( 30 | instanceType: string, 31 | cb: (arg0: { currentPage: Page | Frame }) => void, 32 | ) => { 33 | beforeEach(async () => { 34 | if (instanceType === "Page") { 35 | cb({ currentPage: page }); 36 | } else if (instanceType === "ShadowPage") { 37 | await goToPage(page, "shadow.html", false, cb); 38 | } else if (instanceType === "ShadowFrame") { 39 | await goToPage(page, "shadowFrame.html", true, cb); 40 | } else if (instanceType === "Frame") { 41 | await goToPage(page, "frame.html", true, cb); 42 | } 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toClick.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { setupPage } from "./test-util"; 3 | import { Frame, Page, TimeoutError } from "puppeteer"; 4 | 5 | // import globals 6 | import "jest-puppeteer"; 7 | import "expect-puppeteer"; 8 | 9 | describe("toClick", () => { 10 | beforeEach(async () => { 11 | await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`); 12 | }); 13 | 14 | describe.each(["Page", "Frame"])("%s", (instanceType) => { 15 | let instance: Page | Frame; 16 | 17 | setupPage(instanceType, ({ currentPage }) => { 18 | instance = currentPage; 19 | }); 20 | 21 | it("should click using selector", async () => { 22 | await expect(instance).toClick('a[href="/page2.html"]'); 23 | await instance.waitForNavigation(); 24 | }); 25 | 26 | it("should click using xpath selector", async () => { 27 | await expect(instance).toClick({ 28 | value: '//a[contains(@href,"/page2.html")]', 29 | type: "xpath", 30 | }); 31 | await instance.waitForNavigation(); 32 | }); 33 | 34 | it("should click using css selector with object param", async () => { 35 | await expect(instance).toClick({ 36 | value: 'a[href="/page2.html"]', 37 | type: "css", 38 | }); 39 | await instance.waitForNavigation(); 40 | }); 41 | 42 | it("should click using text", async () => { 43 | await expect(instance).toClick("a", { text: "Page 2" }); 44 | await instance.waitForNavigation(); 45 | }); 46 | 47 | it("should click using text with xpath selector", async () => { 48 | await expect(instance).toClick( 49 | { 50 | value: "//a", 51 | type: "xpath", 52 | }, 53 | { text: "Page 2" }, 54 | ); 55 | await instance.waitForNavigation(); 56 | }); 57 | 58 | it("should click using text with css selector", async () => { 59 | await expect(instance).toClick( 60 | { 61 | value: "a", 62 | type: "css", 63 | }, 64 | { text: "Page 2" }, 65 | ); 66 | await instance.waitForNavigation(); 67 | }); 68 | 69 | it("should return an error if element is not found", async () => { 70 | expect.assertions(3); 71 | 72 | try { 73 | await expect(instance).toClick("a", { text: "Nop" }); 74 | } catch (error: unknown) { 75 | const e = error as TimeoutError; 76 | expect(e.message).toMatch('Element a (text: "Nop") not found'); 77 | expect(e.stack).toMatch(resolve(__filename)); 78 | } 79 | }); 80 | 81 | it("should return an error if element is not found with xpath selector", async () => { 82 | expect.assertions(3); 83 | 84 | try { 85 | await expect(instance).toClick( 86 | { value: "//a", type: "xpath" }, 87 | { text: "Nop" }, 88 | ); 89 | } catch (error: unknown) { 90 | const e = error as TimeoutError; 91 | expect(e.message).toMatch('Element //a (text: "Nop") not found'); 92 | expect(e.stack).toMatch(resolve(__filename)); 93 | } 94 | }); 95 | 96 | it("should return an error if element is not found with css selector as object", async () => { 97 | expect.assertions(3); 98 | 99 | try { 100 | await expect(instance).toClick( 101 | { value: "a", type: "css" }, 102 | { text: "Nop" }, 103 | ); 104 | } catch (error: unknown) { 105 | const e = error as TimeoutError; 106 | expect(e.message).toMatch('Element a (text: "Nop") not found'); 107 | expect(e.stack).toMatch(resolve(__filename)); 108 | } 109 | }); 110 | }); 111 | 112 | describe("ElementHandle", () => { 113 | it("should click using selector", async () => { 114 | const body = await page.$("body"); 115 | await expect(body).toClick('a[href="/page2.html"]'); 116 | await page.waitForSelector("html"); 117 | const pathname = await page.evaluate(() => document.location.pathname); 118 | expect(pathname).toBe("/page2.html"); 119 | }); 120 | 121 | it("should click using xpath selector", async () => { 122 | const body = await page.$("body"); 123 | await expect(body).toClick({ 124 | value: './/a[contains(@href,"/page2.html")]', 125 | type: "xpath", 126 | }); 127 | await page.waitForSelector("html"); 128 | const pathname = await page.evaluate(() => document.location.pathname); 129 | expect(pathname).toBe("/page2.html"); 130 | }); 131 | 132 | it("should click using text", async () => { 133 | const body = await page.$("body"); 134 | await expect(body).toClick("a", { text: "Page 2" }); 135 | await page.waitForSelector("html"); 136 | const pathname = await page.evaluate(() => document.location.pathname); 137 | expect(pathname).toBe("/page2.html"); 138 | }); 139 | 140 | it("should return an error if element is not found", async () => { 141 | const body = await page.$("body"); 142 | expect.assertions(3); 143 | 144 | try { 145 | await expect(body).toClick("a", { text: "Nop" }); 146 | } catch (error: unknown) { 147 | const e = error as TimeoutError; 148 | expect(e.message).toMatch('Element a (text: "Nop") not found'); 149 | expect(e.stack).toMatch(resolve(__filename)); 150 | } 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toClick.ts: -------------------------------------------------------------------------------- 1 | import type { ClickOptions } from "puppeteer"; 2 | import { PuppeteerInstance, Selector } from "../utils"; 3 | import { toMatchElement, ToMatchElementOptions } from "./toMatchElement"; 4 | 5 | export type ToClickOptions = ToMatchElementOptions & ClickOptions; 6 | 7 | export async function toClick( 8 | instance: PuppeteerInstance, 9 | selector: Selector | string, 10 | options: ToClickOptions = {}, 11 | ) { 12 | const { delay, button, count, offset, ...otherOptions } = options; 13 | const element = await toMatchElement(instance, selector, otherOptions); 14 | await element.click({ delay, button, count, offset }); 15 | } 16 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toDisplayDialog.test.ts: -------------------------------------------------------------------------------- 1 | describe("toDisplayDialog", () => { 2 | beforeEach(async () => { 3 | await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`); 4 | }); 5 | 6 | it("should handle dialog", async () => { 7 | const dialog = await expect(page).toDisplayDialog(async () => { 8 | await page.click("#dialog-btn"); 9 | }); 10 | expect(dialog.message()).toBe("Bouh!"); 11 | await dialog.accept(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toDisplayDialog.ts: -------------------------------------------------------------------------------- 1 | import type { Dialog, Page } from "puppeteer"; 2 | 3 | export async function toDisplayDialog(page: Page, block: () => Promise) { 4 | return new Promise((resolve, reject) => { 5 | const handleDialog = (dialog: Dialog) => { 6 | page.off("dialog", handleDialog); 7 | resolve(dialog); 8 | }; 9 | page.on("dialog", handleDialog); 10 | block().catch(reject); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toFill.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { Frame, Page, TimeoutError } from "puppeteer"; 3 | import { setupPage } from "./test-util"; 4 | 5 | // import globals 6 | import "jest-puppeteer"; 7 | import "expect-puppeteer"; 8 | 9 | describe("toFill", () => { 10 | beforeEach(async () => { 11 | await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`); 12 | }); 13 | 14 | describe.each(["Page", "Frame"])("%s", (instanceType) => { 15 | let instance: Page | Frame; 16 | setupPage(instanceType, ({ currentPage }) => { 17 | instance = currentPage; 18 | }); 19 | it("should fill input", async () => { 20 | await expect(instance).toFill('[name="firstName"]', "James"); 21 | const value = await instance.evaluate( 22 | () => 23 | document.querySelector('[name="firstName"]')!.value, 24 | ); 25 | expect(value).toBe("James"); 26 | }); 27 | 28 | it("should empty the input given an empty string", async () => { 29 | await expect(instance).toFill('[name="firstName"]', "James"); 30 | await expect(instance).toFill('[name="firstName"]', ""); 31 | const value = await instance.evaluate( 32 | () => 33 | document.querySelector('[name="firstName"]')!.value, 34 | ); 35 | expect(value).toBe(""); 36 | }); 37 | 38 | it("should fill textarea", async () => { 39 | await expect(instance).toFill( 40 | '[name="notes"]', 41 | "These are \n multiline \n notes", 42 | ); 43 | const value = await instance.evaluate( 44 | () => document.querySelector('[name="notes"]')!.value, 45 | ); 46 | expect(value).toBe("These are \n multiline \n notes"); 47 | }); 48 | 49 | it("should empty the textarea given an empty string", async () => { 50 | await expect(instance).toFill( 51 | '[name="notes"]', 52 | "These are \n multiline \n notes", 53 | ); 54 | await expect(instance).toFill('[name="notes"]', ""); 55 | const value = await instance.evaluate( 56 | () => document.querySelector('[name="notes"]')!.value, 57 | ); 58 | expect(value).toBe(""); 59 | }); 60 | 61 | it("should fill number input", async () => { 62 | await expect(instance).toFill('[name="age"]', "10"); 63 | const value = await instance.evaluate( 64 | () => document.querySelector('[name="age"]')!.value, 65 | ); 66 | expect(value).toBe("10"); 67 | }); 68 | 69 | it("should empty number input given an empty string", async () => { 70 | await expect(instance).toFill('[name="age"]', "10"); 71 | await expect(instance).toFill('[name="age"]', ""); 72 | const value = await instance.evaluate( 73 | () => document.querySelector('[name="age"]')!.value, 74 | ); 75 | expect(value).toBe(""); 76 | }); 77 | 78 | it("should return an error if text is not in the instance", async () => { 79 | expect.assertions(3); 80 | 81 | try { 82 | await expect(instance).toFill('[name="notFound"]', "James"); 83 | } catch (error: unknown) { 84 | const e = error as TimeoutError; 85 | expect(e.message).toMatch('Element [name="notFound"] not found'); 86 | expect(e.stack).toMatch(resolve(__filename)); 87 | } 88 | }); 89 | }); 90 | 91 | describe("ElementHandle", () => { 92 | it("should fill input", async () => { 93 | const body = await page.$("body"); 94 | await expect(body).toFill('[name="firstName"]', "James"); 95 | const value = await page.evaluate( 96 | () => 97 | document.querySelector('[name="firstName"]')!.value, 98 | ); 99 | expect(value).toBe("James"); 100 | }); 101 | 102 | it("should fill input with custom delay", async () => { 103 | const body = await page.$("body"); 104 | await expect(body).toFill('[name="firstName"]', "James", { 105 | delay: 50, 106 | }); 107 | const value = await page.evaluate( 108 | () => 109 | document.querySelector('[name="firstName"]')!.value, 110 | ); 111 | expect(value).toBe("James"); 112 | }); 113 | 114 | it("should return an error if text is not in the page", async () => { 115 | const body = await page.$("body"); 116 | expect.assertions(3); 117 | 118 | try { 119 | await expect(body).toFill('[name="notFound"]', "James"); 120 | } catch (error: unknown) { 121 | const e = error as TimeoutError; 122 | expect(e.message).toMatch('Element [name="notFound"] not found'); 123 | expect(e.stack).toMatch(resolve(__filename)); 124 | } 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toFill.ts: -------------------------------------------------------------------------------- 1 | import type { ElementHandle } from "puppeteer"; 2 | import { PuppeteerInstance, Selector } from "../utils"; 3 | import { toMatchElement, ToMatchElementOptions } from "./toMatchElement"; 4 | 5 | async function selectAll(element: ElementHandle) { 6 | // modified from https://github.com/microsoft/playwright/issues/849#issuecomment-587983363 7 | await element.evaluate((element) => { 8 | if ( 9 | !( 10 | element instanceof HTMLInputElement || 11 | element instanceof HTMLTextAreaElement 12 | ) 13 | ) { 14 | throw new Error(`Element is not an element.`); 15 | } 16 | 17 | if (element.setSelectionRange) { 18 | try { 19 | element.setSelectionRange(0, element.value.length); 20 | } catch { 21 | // setSelectionRange throws an error for inputs: number/date/time/etc 22 | // we can just focus them and the content will be selected 23 | element.focus(); 24 | element.select(); 25 | } 26 | } else if (window.getSelection && document.createRange) { 27 | const range = document.createRange(); 28 | range.selectNodeContents(element); 29 | 30 | const selection = window.getSelection(); 31 | if (selection) { 32 | selection.removeAllRanges(); 33 | selection.addRange(range); 34 | } 35 | } 36 | }); 37 | } 38 | 39 | export type ToFillOptions = ToMatchElementOptions & { 40 | delay?: number; 41 | }; 42 | 43 | export async function toFill( 44 | instance: PuppeteerInstance, 45 | selector: Selector | string, 46 | value: string, 47 | options: ToFillOptions = {}, 48 | ) { 49 | const { delay, ...toMatchElementOptions } = options; 50 | const element = await toMatchElement( 51 | instance, 52 | selector, 53 | toMatchElementOptions, 54 | ); 55 | await selectAll(element); 56 | await element.press("Delete"); 57 | await element.type(value, delay ? { delay } : undefined); 58 | } 59 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toFillForm.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { Frame, Page, TimeoutError } from "puppeteer"; 3 | import { setupPage } from "./test-util"; 4 | 5 | // import globals 6 | import "jest-puppeteer"; 7 | import "expect-puppeteer"; 8 | 9 | describe("toFillForm", () => { 10 | beforeEach(async () => { 11 | await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`); 12 | }); 13 | 14 | describe.each(["Page", "Frame"])("%s", (instanceType) => { 15 | let instance: Page | Frame; 16 | setupPage(instanceType, ({ currentPage }) => { 17 | instance = currentPage; 18 | }); 19 | it("should fill input", async () => { 20 | await expect(instance).toFillForm("form", { 21 | firstName: "James", 22 | lastName: "Bond", 23 | }); 24 | const values = await instance.evaluate(() => ({ 25 | firstName: 26 | document.querySelector('[name="firstName"]')!.value, 27 | lastName: 28 | document.querySelector('[name="lastName"]')!.value, 29 | })); 30 | expect(values).toEqual({ 31 | firstName: "James", 32 | lastName: "Bond", 33 | }); 34 | }); 35 | 36 | it("should return an error if text is not in the page", async () => { 37 | expect.assertions(3); 38 | 39 | try { 40 | await expect(instance).toFillForm('form[name="notFound"]', { 41 | firstName: "James", 42 | lastName: "Bond", 43 | }); 44 | } catch (error: unknown) { 45 | const e = error as TimeoutError; 46 | expect(e.message).toMatch('Element form[name="notFound"] not found'); 47 | expect(e.stack).toMatch(resolve(__filename)); 48 | } 49 | }); 50 | }); 51 | 52 | describe("ElementHandle", () => { 53 | it("should fill input", async () => { 54 | const body = await page.$("body"); 55 | await expect(body).toFillForm("form", { 56 | firstName: "James", 57 | lastName: "Bond", 58 | }); 59 | const values = await page.evaluate(() => ({ 60 | firstName: 61 | document.querySelector('[name="firstName"]')!.value, 62 | lastName: 63 | document.querySelector('[name="lastName"]')!.value, 64 | })); 65 | expect(values).toEqual({ 66 | firstName: "James", 67 | lastName: "Bond", 68 | }); 69 | }); 70 | 71 | it("should return an error if text is not in the page", async () => { 72 | const body = await page.$("body"); 73 | expect.assertions(3); 74 | 75 | try { 76 | await expect(body).toFillForm('form[name="notFound"]', { 77 | firstName: "James", 78 | lastName: "Bond", 79 | }); 80 | } catch (error: unknown) { 81 | const e = error as TimeoutError; 82 | expect(e.message).toMatch('Element form[name="notFound"] not found'); 83 | expect(e.stack).toMatch(resolve(__filename)); 84 | } 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toFillForm.ts: -------------------------------------------------------------------------------- 1 | import { PuppeteerInstance, Selector } from "../utils"; 2 | import { toFill, ToFillOptions } from "./toFill"; 3 | import { toMatchElement, ToMatchElementOptions } from "./toMatchElement"; 4 | 5 | export type ToFillFormOptions = ToFillOptions & ToMatchElementOptions; 6 | 7 | export async function toFillForm( 8 | instance: PuppeteerInstance, 9 | selector: Selector | string, 10 | values: Record, 11 | options: ToFillFormOptions = {}, 12 | ) { 13 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 14 | const { delay, ...otherOptions } = options; 15 | const form = await toMatchElement(instance, selector, otherOptions); 16 | 17 | for (const name of Object.keys(values)) { 18 | await toFill(form, `[name="${name}"]`, values[name], options); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toMatchElement.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { setupPage } from "./test-util"; 3 | import { Frame, Page, TimeoutError } from "puppeteer"; 4 | 5 | // import globals 6 | import "jest-puppeteer"; 7 | import "expect-puppeteer"; 8 | 9 | describe("toMatchElement", () => { 10 | beforeEach(async () => { 11 | await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`); 12 | }); 13 | 14 | describe.each(["Page", "Frame"])("%s", (instanceType) => { 15 | let instance: Page | Frame; 16 | setupPage(instanceType, ({ currentPage }) => { 17 | instance = currentPage; 18 | }); 19 | it("should match using selector", async () => { 20 | const element = await expect(instance).toMatchElement( 21 | 'a[href="/page2.html"]', 22 | ); 23 | const textContentProperty = await element.getProperty("textContent"); 24 | const textContent = await textContentProperty.jsonValue(); 25 | expect(textContent).toBe("Page 2"); 26 | }); 27 | 28 | it("should match using text (string)", async () => { 29 | const element = await expect(instance).toMatchElement("a", { 30 | text: "Page 2", 31 | }); 32 | const textContentProperty = await element.getProperty("textContent"); 33 | const textContent = await textContentProperty.jsonValue(); 34 | expect(textContent).toBe("Page 2"); 35 | }); 36 | 37 | it("should match using text (RegExp)", async () => { 38 | const element = await expect(instance).toMatchElement("a", { 39 | text: /Page\s2/, 40 | }); 41 | const textContentProperty = await element.getProperty("textContent"); 42 | const textContent = await textContentProperty.jsonValue(); 43 | expect(textContent).toBe("Page 2"); 44 | }); 45 | 46 | it("should return an error if element is not found", async () => { 47 | expect.assertions(4); 48 | 49 | try { 50 | await expect(instance).toMatchElement("a", { text: "Nop" }); 51 | } catch (error: unknown) { 52 | const e = error as TimeoutError; 53 | expect(e.message).toMatch('Element a (text: "Nop") not found'); 54 | expect(e.message).toMatch("Waiting failed: 500ms exceeded"); 55 | expect(e.stack).toMatch(resolve(__filename)); 56 | } 57 | }); 58 | 59 | it("should match using visible options", async () => { 60 | expect.assertions(11); 61 | 62 | const normalElement = await expect(instance).toMatchElement(".normal", { 63 | visible: true, 64 | }); 65 | const textContentProperty = 66 | await normalElement.getProperty("textContent"); 67 | const textContent = await textContentProperty.jsonValue(); 68 | expect(textContent).toBe("normal element"); 69 | 70 | try { 71 | await expect(instance).toMatchElement(".hidden", { visible: true }); 72 | } catch (error: unknown) { 73 | const e = error as TimeoutError; 74 | expect(e.message).toMatch("Element .hidden not found"); 75 | expect(e.message).toMatch("Waiting failed: 500ms exceeded"); 76 | } 77 | 78 | try { 79 | await expect(instance).toMatchElement(".displayed", { 80 | visible: true, 81 | }); 82 | } catch (error: unknown) { 83 | const e = error as TimeoutError; 84 | expect(e.message).toMatch("Element .displayed not found"); 85 | expect(e.message).toMatch("Waiting failed: 500ms exceeded"); 86 | } 87 | 88 | try { 89 | await expect(instance).toMatchElement(".displayedWithClassname", { 90 | visible: true, 91 | }); 92 | } catch (error: unknown) { 93 | const e = error as TimeoutError; 94 | expect(e.message).toMatch("Element .displayedWithClassname not found"); 95 | expect(e.message).toMatch("Waiting failed: 500ms exceeded"); 96 | } 97 | }); 98 | }); 99 | 100 | describe("ElementHandle", () => { 101 | it("should match using selector", async () => { 102 | const main = await page.$("main"); 103 | const element = await expect(main).toMatchElement("#in-the-main"); 104 | const textContentProperty = await element.getProperty("textContent"); 105 | const textContent = await textContentProperty.jsonValue(); 106 | expect(textContent).toMatch("A div in the main"); 107 | }); 108 | 109 | it("should match using text (string)", async () => { 110 | const main = await page.$("main"); 111 | const element = await expect(main).toMatchElement("*", { 112 | text: "in the main", 113 | }); 114 | const textContentProperty = await element.getProperty("textContent"); 115 | const textContent = await textContentProperty.jsonValue(); 116 | expect(textContent).toMatch("A div in the main"); 117 | }); 118 | 119 | it("should match using text (RegExp)", async () => { 120 | const main = await page.$("main"); 121 | const element = await expect(main).toMatchElement("*", { 122 | text: /in.the\smain/g, 123 | }); 124 | const textContentProperty = await element.getProperty("textContent"); 125 | const textContent = await textContentProperty.jsonValue(); 126 | expect(textContent).toMatch("A div in the main"); 127 | }); 128 | 129 | it("should return an error if element is not found", async () => { 130 | const main = await page.$("main"); 131 | expect.assertions(3); 132 | 133 | try { 134 | await expect(main).toMatchElement("a", { text: "Page 2" }); 135 | } catch (error: unknown) { 136 | const e = error as TimeoutError; 137 | expect(e.message).toMatch('Element a (text: "Page 2") not found'); 138 | expect(e.message).toMatch("Waiting failed: 500ms exceeded"); 139 | } 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toMatchElement.ts: -------------------------------------------------------------------------------- 1 | import { 2 | enhanceError, 3 | PuppeteerInstance, 4 | Selector, 5 | resolveSelector, 6 | getSelectorMessage, 7 | } from "../utils"; 8 | import type { ElementHandle } from "puppeteer"; 9 | import { defaultOptions, Options } from "../options"; 10 | import { getElementFactory, GetElementOptions } from "./getElementFactory"; 11 | 12 | export type ToMatchElementOptions = GetElementOptions & Options; 13 | 14 | export async function toMatchElement( 15 | instance: PuppeteerInstance, 16 | selector: Selector | string, 17 | options: ToMatchElementOptions = {}, 18 | ) { 19 | const { text, visible, ...otherOptions } = options; 20 | const frameOptions = defaultOptions(otherOptions); 21 | const rSelector = resolveSelector(selector); 22 | 23 | const [getElement, getElementArgs, ctx] = await getElementFactory( 24 | instance, 25 | rSelector, 26 | { text, visible }, 27 | ); 28 | 29 | try { 30 | await ctx.page.waitForFunction( 31 | getElement, 32 | frameOptions, 33 | ...getElementArgs, 34 | "positive" as const, 35 | ); 36 | } catch (error: any) { 37 | throw enhanceError( 38 | error, 39 | `${getSelectorMessage(rSelector, text)} not found`, 40 | ); 41 | } 42 | 43 | const jsHandle = await ctx.page.evaluateHandle( 44 | getElement, 45 | ...getElementArgs, 46 | "element" as const, 47 | ); 48 | return jsHandle.asElement() as ElementHandle; 49 | } 50 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toMatchTextContent.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { setupPage } from "./test-util"; 3 | import { Frame, Page, TimeoutError } from "puppeteer"; 4 | 5 | // import globals 6 | import "jest-puppeteer"; 7 | import "expect-puppeteer"; 8 | 9 | describe("toMatchTextContent", () => { 10 | beforeEach(async () => { 11 | await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`); 12 | }); 13 | 14 | describe.each(["Page", "Frame", "ShadowPage", "ShadowFrame"])( 15 | "%s", 16 | (instanceType) => { 17 | let instance: Page | Frame; 18 | setupPage(instanceType, ({ currentPage }) => { 19 | instance = currentPage; 20 | }); 21 | 22 | const options = ["ShadowPage", "ShadowFrame"].includes(instanceType) 23 | ? { traverseShadowRoots: true } 24 | : {}; 25 | 26 | it("should be ok if text is in the page", async () => { 27 | await expect(instance).toMatchTextContent("This is home!", options); 28 | }); 29 | 30 | it("should support RegExp", async () => { 31 | await expect(instance).toMatchTextContent(/THIS.is.home/i, options); 32 | }); 33 | 34 | it("should return an error if text is not in the page", async () => { 35 | expect.assertions(4); 36 | 37 | try { 38 | await expect(instance).toMatchTextContent("Nop", options); 39 | } catch (error: unknown) { 40 | const e = error as TimeoutError; 41 | expect(e.message).toMatch('Text not found "Nop"'); 42 | expect(e.message).toMatch("Waiting failed: 500ms exceeded"); 43 | expect(e.stack).toMatch(resolve(__filename)); 44 | } 45 | }); 46 | }, 47 | ); 48 | 49 | describe("ElementHandle", () => { 50 | it("should be ok if text is in the page", async () => { 51 | const dialogBtn = await page.$("#dialog-btn"); 52 | await expect(dialogBtn).toMatchTextContent("Open dialog"); 53 | }); 54 | 55 | it("should support RegExp", async () => { 56 | const dialogBtn = await page.$("#dialog-btn"); 57 | await expect(dialogBtn).toMatchTextContent(/OPEN/i); 58 | }); 59 | 60 | it("should return an error if text is not in the page", async () => { 61 | expect.assertions(4); 62 | const dialogBtn = await page.$("#dialog-btn"); 63 | 64 | try { 65 | await expect(dialogBtn).toMatchTextContent("This is home!"); 66 | } catch (error: unknown) { 67 | const e = error as TimeoutError; 68 | expect(e.message).toMatch('Text not found "This is home!"'); 69 | expect(e.message).toMatch("Waiting failed: 500ms exceeded"); 70 | expect(e.stack).toMatch(resolve(__filename)); 71 | } 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toMatchTextContent.ts: -------------------------------------------------------------------------------- 1 | import { enhanceError, PuppeteerInstance, SearchExpression } from "../utils"; 2 | import { matchTextContent, MatchTextContentOptions } from "./matchTextContent"; 3 | 4 | export type ToMatchOptions = MatchTextContentOptions; 5 | 6 | export async function toMatchTextContent( 7 | instance: PuppeteerInstance, 8 | matcher: SearchExpression, 9 | options: ToMatchOptions = {}, 10 | ) { 11 | try { 12 | await matchTextContent(instance, matcher, options, "positive"); 13 | } catch (error: any) { 14 | throw enhanceError(error, `Text not found "${matcher}"`); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toSelect.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "node:path"; 2 | import { Frame, Page, TimeoutError } from "puppeteer"; 3 | import { setupPage } from "./test-util"; 4 | 5 | // import globals 6 | import "jest-puppeteer"; 7 | import "expect-puppeteer"; 8 | 9 | describe("toSelect", () => { 10 | beforeEach(async () => { 11 | await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`); 12 | }); 13 | 14 | describe.each(["Page", "Frame"])("%s", (instanceType) => { 15 | let instance: Page | Frame; 16 | setupPage(instanceType, ({ currentPage }) => { 17 | instance = currentPage; 18 | }); 19 | it("should select an option using value", async () => { 20 | await expect(instance).toSelect('select[name="my-select"]', "opt1"); 21 | const currentValue = await instance.evaluate( 22 | () => 23 | document.querySelector('select[name="my-select"]')! 24 | .value, 25 | ); 26 | expect(currentValue).toBe("opt1"); 27 | }); 28 | 29 | it("should select an option using text", async () => { 30 | await expect(instance).toSelect('select[name="my-select"]', "Option 2"); 31 | const currentValue = await instance.evaluate( 32 | () => 33 | document.querySelector('select[name="my-select"]')! 34 | .value, 35 | ); 36 | expect(currentValue).toBe("opt2"); 37 | }); 38 | 39 | it("should return an error if option is not found", async () => { 40 | expect.assertions(3); 41 | 42 | try { 43 | await expect(instance).toSelect( 44 | 'select[name="my-select"]', 45 | "Another world", 46 | ); 47 | } catch (error: unknown) { 48 | const e = error as TimeoutError; 49 | expect(e.message).toMatch( 50 | 'Option not found "select[name="my-select"]" ("Another world")', 51 | ); 52 | expect(e.stack).toMatch(resolve(__filename)); 53 | } 54 | }); 55 | }); 56 | 57 | describe("ElementHandle", () => { 58 | it("should select an option using value", async () => { 59 | const body = await page.$("body"); 60 | await expect(body).toSelect('select[name="my-select"]', "opt1"); 61 | const currentValue = await page.evaluate( 62 | () => 63 | document.querySelector('select[name="my-select"]')! 64 | .value, 65 | ); 66 | expect(currentValue).toBe("opt1"); 67 | }); 68 | 69 | it("should select an option using text", async () => { 70 | const body = await page.$("body"); 71 | await expect(body).toSelect('select[name="my-select"]', "Option 2"); 72 | const currentValue = await page.evaluate( 73 | () => 74 | document.querySelector('select[name="my-select"]')! 75 | .value, 76 | ); 77 | expect(currentValue).toBe("opt2"); 78 | }); 79 | 80 | it("should return an error if option is not found", async () => { 81 | const body = await page.$("body"); 82 | expect.assertions(3); 83 | 84 | try { 85 | await expect(body).toSelect( 86 | 'select[name="my-select"]', 87 | "Another world", 88 | ); 89 | } catch (error: unknown) { 90 | const e = error as TimeoutError; 91 | expect(e.message).toMatch( 92 | 'Option not found "select[name="my-select"]" ("Another world")', 93 | ); 94 | expect(e.stack).toMatch(resolve(__filename)); 95 | } 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /packages/expect-puppeteer/src/matchers/toSelect.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-syntax */ 2 | import { toMatchElement, ToMatchElementOptions } from "./toMatchElement"; 3 | import { PuppeteerInstance, resolveSelector, Selector } from "../utils"; 4 | import type { ElementHandle } from "puppeteer"; 5 | 6 | const checkIsSelectElement = ( 7 | element: ElementHandle, 8 | ): element is ElementHandle => { 9 | return typeof element.select === "function"; 10 | }; 11 | 12 | export async function toSelect( 13 | instance: PuppeteerInstance, 14 | selector: Selector | string, 15 | valueOrText: string, 16 | options: ToMatchElementOptions = {}, 17 | ) { 18 | const element = await toMatchElement(instance, selector, options); 19 | 20 | if (!checkIsSelectElement(element)) { 21 | throw new Error(`Element is not a 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 |
28 | 31 |
32 | The main content of the page: it rocks!! 33 |
A div in the main
34 | 35 | 36 |
displayed element
37 |
normal element
38 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /server/public/page2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test App 6 | 7 | 8 |
This is Page 2
9 | Home 10 | 11 | 12 | -------------------------------------------------------------------------------- /server/public/shadow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test App 6 | 7 | 8 | 9 | 13 |

Light DOM content (slotted)

14 |
15 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /server/public/shadowFrame.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules/**"], 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "target": "ES2021", 8 | "importHelpers": true, 9 | "declaration": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "allowSyntheticDefaultImports": true, 17 | "noEmit": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------