├── .commitlintrc ├── .editorconfig ├── .eslintrc ├── .firebaserc ├── .github ├── dependabot.yml └── workflows │ ├── check-linked-issues.yml │ ├── ci.yml │ ├── notify-release.yml │ └── release.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── babel.config.cjs ├── example ├── .env.sample ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── playwright.config.ts ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── hooks │ │ └── useAuth.tsx │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── provider │ │ └── AuthProvider.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ └── setupTests.ts ├── test-examples │ ├── auth.setup.ts │ └── example.spec.ts └── tsconfig.json ├── index.ts ├── jest.config.ts ├── package-lock.json ├── package.json ├── plugin ├── Authentication.ts └── auth.setup.ts ├── tests ├── Authentication.test.ts └── authsetup.test.ts └── tsconfig.json /.commitlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "commonjs": true 5 | }, 6 | "root": true, 7 | "parser": "@typescript-eslint/parser", 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/eslint-recommended", 14 | "plugin:@typescript-eslint/recommended" 15 | ], 16 | "ignorePatterns": [ 17 | "/dist/**/*" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "playwright-firebase-plugin" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /.github/workflows/check-linked-issues.yml: -------------------------------------------------------------------------------- 1 | name: Check linked issues 2 | 'on': 3 | pull_request_target: 4 | types: 5 | - opened 6 | - edited 7 | - reopened 8 | - synchronize 9 | jobs: 10 | check_pull_requests: 11 | runs-on: ubuntu-latest 12 | name: Check linked issues 13 | permissions: 14 | issues: read 15 | pull-requests: write 16 | steps: 17 | - uses: nearform-actions/github-action-check-linked-issues@v1 18 | id: check-linked-issues 19 | with: 20 | exclude-branches: release/**, dependabot/** 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: Lint and test 12 | runs-on: ubuntu-latest 13 | env: 14 | REACT_APP_UID: ${{ secrets.REACT_APP_UID }} 15 | REACT_APP_FIREBASE_CONFIG: ${{ secrets.REACT_APP_FIREBASE_CONFIG }} 16 | SERVICE_ACCOUNT: ${{ secrets.SERVICE_ACCOUNT }} 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version-file: '.nvmrc' 22 | - run: | 23 | npm ci 24 | npm run pw 25 | npm run lint 26 | npm run test 27 | 28 | automerge: 29 | name: Merge dependabot's PRs 30 | needs: test 31 | runs-on: ubuntu-latest 32 | permissions: 33 | pull-requests: write 34 | contents: write 35 | steps: 36 | - uses: fastify/github-action-merge-dependabot@v3 37 | -------------------------------------------------------------------------------- /.github/workflows/notify-release.yml: -------------------------------------------------------------------------------- 1 | name: Notify release 2 | 'on': 3 | workflow_dispatch: 4 | schedule: 5 | - cron: 30 8 * * * 6 | release: 7 | types: 8 | - published 9 | issues: 10 | types: 11 | - closed 12 | jobs: 13 | setup: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | issues: write 17 | contents: read 18 | steps: 19 | - uses: nearform-actions/github-action-notify-release@v1 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | semver: 6 | description: The semver to use 7 | required: true 8 | default: patch 9 | type: choice 10 | options: 11 | - patch 12 | - minor 13 | - major 14 | pull_request: 15 | types: [closed] 16 | 17 | jobs: 18 | release: 19 | permissions: 20 | contents: write 21 | issues: write 22 | pull-requests: write 23 | id-token: write 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: nearform-actions/optic-release-automation-action@v4 27 | with: 28 | semver: ${{ github.event.inputs.semver }} 29 | commit-message: 'chore: release {version}' 30 | npm-token: >- 31 | ${{ secrets[format('NPM_TOKEN_{0}', github.actor)] || 32 | secrets.NPM_TOKEN }} 33 | optic-token: >- 34 | ${{ secrets[format('OPTIC_TOKEN_{0}', github.actor)] || 35 | secrets.OPTIC_TOKEN }} 36 | build-command: npm ci 37 | provenance: true 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .eslintcache 3 | 4 | # JetBrains IDEs 5 | .idea 6 | # Visual Studio Code 7 | .vscode 8 | 9 | # OS X 10 | .DS_Store 11 | /test-results/ 12 | /playwright-report/ 13 | /playwright/.cache/ 14 | .auth 15 | serviceAccount.json 16 | .env 17 | dist 18 | 19 | 20 | cjs -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | 3 | Version 2.0, January 2004 4 | 5 | http://www.apache.org/licenses/ TERMS 6 | AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | 11 | 12 | 13 | "License" shall mean the terms and conditions for use, reproduction, and 14 | distribution as defined by Sections 1 through 9 of this document. 15 | 16 | 17 | 18 | 19 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 20 | owner that is granting the License. 21 | 22 | 23 | 24 | "Legal Entity" shall mean the 25 | union of the acting entity and all other entities that control, are controlled 26 | by, or are under common control with that entity. For the purposes of this 27 | definition, "control" means (i) the power, direct or indirect, to cause the 28 | direction or management of such entity, whether by contract or otherwise, or (ii) 29 | ownership of fifty percent (50%) or more of the outstanding shares, or (iii) 30 | beneficial ownership of such entity. 31 | 32 | 33 | 34 | "You" (or "Your") shall mean 35 | an individual or Legal Entity exercising permissions granted by this License. 36 | 37 | 38 | 39 | 40 | "Source" form shall mean the preferred form for making modifications, 41 | including but not limited to software source code, documentation source, and 42 | configuration files. 43 | 44 | 45 | 46 | "Object" form shall mean any form resulting 47 | from mechanical transformation or translation of a Source form, including but not 48 | limited to compiled object code, generated documentation, and conversions to 49 | other media types. 50 | 51 | 52 | 53 | "Work" shall mean the work of authorship, 54 | whether in Source or Object form, made available under the License, as indicated 55 | by a copyright notice that is included in or attached to the work (an example is 56 | provided in the Appendix below). 57 | 58 | 59 | 60 | "Derivative Works" shall mean any 61 | work, whether in Source or Object form, that is based on (or derived from) the 62 | Work and for which the editorial revisions, annotations, elaborations, or other 63 | modifications represent, as a whole, an original work of authorship. For the 64 | purposes of this License, Derivative Works shall not include works that remain 65 | separable from, or merely link (or bind by name) to the interfaces of, the Work 66 | and Derivative Works thereof. 67 | 68 | 69 | 70 | "Contribution" shall mean any work 71 | of authorship, including the original version of the Work and any modifications 72 | or additions to that Work or Derivative Works thereof, that is intentionally 73 | submitted to Licensor for inclusion in the Work by the copyright owner or by an 74 | individual or Legal Entity authorized to submit on behalf of the copyright owner. 75 | For the purposes of this definition, "submitted" means any form of electronic, 76 | verbal, or written communication sent to the Licensor or its representatives, 77 | including but not limited to communication on electronic mailing lists, source 78 | code control systems, and issue tracking systems that are managed by, or on 79 | behalf of, the Licensor for the purpose of discussing and improving the Work, but 80 | excluding communication that is conspicuously marked or otherwise designated in 81 | writing by the copyright owner as "Not a Contribution." 82 | 83 | 84 | 85 | 86 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of 87 | whom a Contribution has been received by Licensor and subsequently incorporated 88 | within the Work. 89 | 90 | 2. Grant of Copyright License. Subject to the terms and 91 | conditions of this License, each Contributor hereby grants to You a perpetual, 92 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license 93 | to reproduce, prepare Derivative Works of, publicly display, publicly perform, 94 | sublicense, and distribute the Work and such Derivative Works in Source or Object 95 | form. 96 | 97 | 3. Grant of Patent License. Subject to the terms and conditions of this 98 | License, each Contributor hereby grants to You a perpetual, worldwide, 99 | non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this 100 | section) patent license to make, have made, use, offer to sell, sell, import, and 101 | otherwise transfer the Work, where such license applies only to those patent 102 | claims licensable by such Contributor that are necessarily infringed by their 103 | Contribution(s) alone or by combination of their Contribution(s) with the Work to 104 | which such Contribution(s) was submitted. If You institute patent litigation 105 | against any entity (including a cross-claim or counterclaim in a lawsuit) 106 | alleging that the Work or a Contribution incorporated within the Work constitutes 107 | direct or contributory patent infringement, then any patent licenses granted to 108 | You under this License for that Work shall terminate as of the date such 109 | litigation is filed. 110 | 111 | 4. Redistribution. You may reproduce and distribute 112 | copies of the Work or Derivative Works thereof in any medium, with or without 113 | modifications, and in Source or Object form, provided that You meet the following 114 | conditions: 115 | 116 | (a) You must give any other recipients of the Work or 117 | Derivative Works a copy of this License; and 118 | 119 | (b) You must cause any 120 | modified files to carry prominent notices stating that You changed the files; 121 | and 122 | 123 | (c) You must retain, in the Source form of any Derivative Works that 124 | You distribute, all copyright, patent, trademark, and attribution notices from 125 | the Source form of the Work, excluding those notices that do not pertain to any 126 | part of the Derivative Works; and 127 | 128 | (d) If the Work includes a "NOTICE" text 129 | file as part of its distribution, then any Derivative Works that You distribute 130 | must include a readable copy of the attribution notices contained within such 131 | NOTICE file, excluding those notices that do not pertain to any part of the 132 | Derivative Works, in at least one of the following places: within a NOTICE text 133 | file distributed as part of the Derivative Works; within the Source form or 134 | documentation, if provided along with the Derivative Works; or, within a display 135 | generated by the Derivative Works, if and wherever such third-party notices 136 | normally appear. The contents of the NOTICE file are for informational purposes 137 | only and do not modify the License. You may add Your own attribution notices 138 | within Derivative Works that You distribute, alongside or as an addendum to the 139 | NOTICE text from the Work, provided that such additional attribution notices 140 | cannot be construed as modifying the License. 141 | 142 | You may add Your own 143 | copyright statement to Your modifications and may provide additional or different 144 | license terms and conditions for use, reproduction, or distribution of Your 145 | modifications, or for any such Derivative Works as a whole, provided Your use, 146 | reproduction, and distribution of the Work otherwise complies with the conditions 147 | stated in this License. 148 | 149 | 5. Submission of Contributions. Unless You explicitly 150 | state otherwise, any Contribution intentionally submitted for inclusion in the 151 | Work by You to the Licensor shall be under the terms and conditions of this 152 | License, without any additional terms or conditions. Notwithstanding the above, 153 | nothing herein shall supersede or modify the terms of any separate license 154 | agreement you may have executed with Licensor regarding such Contributions. 155 | 156 | 157 | 6. Trademarks. This License does not grant permission to use the trade names, 158 | trademarks, service marks, or product names of the Licensor, except as required 159 | for reasonable and customary use in describing the origin of the Work and 160 | reproducing the content of the NOTICE file. 161 | 162 | 7. Disclaimer of Warranty. Unless 163 | required by applicable law or agreed to in writing, Licensor provides the Work 164 | (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT 165 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, 166 | without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, 167 | MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible 168 | for determining the appropriateness of using or redistributing the Work and 169 | assume any risks associated with Your exercise of permissions under this 170 | License. 171 | 172 | 8. Limitation of Liability. In no event and under no legal theory, 173 | whether in tort (including negligence), contract, or otherwise, unless required 174 | by applicable law (such as deliberate and grossly negligent acts) or agreed to in 175 | writing, shall any Contributor be liable to You for damages, including any 176 | direct, indirect, special, incidental, or consequential damages of any character 177 | arising as a result of this License or out of the use or inability to use the 178 | Work (including but not limited to damages for loss of goodwill, work stoppage, 179 | computer failure or malfunction, or any and all other commercial damages or 180 | losses), even if such Contributor has been advised of the possibility of such 181 | damages. 182 | 183 | 9. Accepting Warranty or Additional Liability. While redistributing 184 | the Work or Derivative Works thereof, You may choose to offer, and charge a fee 185 | for, acceptance of support, warranty, indemnity, or other liability obligations 186 | and/or rights consistent with this License. However, in accepting such 187 | obligations, You may act only on Your own behalf and on Your sole responsibility, 188 | not on behalf of any other Contributor, and only if You agree to indemnify, 189 | defend, and hold each Contributor harmless for any liability incurred by, or 190 | claims asserted against, such Contributor by reason of your accepting any such 191 | warranty or additional liability. END OF TERMS AND CONDITIONS 192 | 193 | APPENDIX: How to 194 | apply the Apache License to your work. 195 | 196 | To apply the Apache License to your work, 197 | attach the following boilerplate notice, with the fields enclosed by brackets 198 | "[]" replaced with your own identifying information. (Don't include the 199 | brackets!) The text should be enclosed in the appropriate comment syntax for the 200 | file format. We also recommend that a file or class name and description of 201 | purpose be included on the same "printed page" as the copyright notice for easier 202 | identification within third-party archives. 203 | 204 | Copyright [yyyy] Steve 205 | Goode 206 | 207 | Licensed under the Apache License, Version 2.0 (the "License"); 208 | 209 | you may 210 | not use this file except in compliance with the License. 211 | 212 | You may obtain a copy 213 | of the License at 214 | 215 | http://www.apache.org/licenses/LICENSE-2.0 216 | 217 | Unless required by 218 | applicable law or agreed to in writing, software 219 | 220 | distributed under the License 221 | is distributed on an "AS IS" BASIS, 222 | 223 | WITHOUT WARRANTIES OR CONDITIONS OF ANY 224 | KIND, either express or implied. 225 | 226 | See the License for the specific language 227 | governing permissions and 228 | 229 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Continuous Integration](https://github.com/nearform/playwright-firebase/actions/workflows/ci.yml/badge.svg)](https://github.com/nearform/playwright-firebase/actions/workflows/ci.yml) 2 | 3 | # @nearform/playwright-firebase 4 | 5 | > [!NOTE] 6 | > This is an ESM only package, please make sure your usage follows the rules of ESM 7 | > User the following [guide](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c#pure-esm-package) to learn more 8 | 9 | Tidy way to authenticate Playwright E2E tests on Firebase. 10 | 11 | Install using npm: 12 | 13 | ```bash 14 | npm install @nearform/playwright-firebase 15 | ``` 16 | 17 | or yarn: 18 | 19 | ```bash 20 | yarn add @nearform/playwright-firebase 21 | ``` 22 | 23 | Want to see it in action? Go to [Example](#example) to try it out! 24 | 25 | ## Contents 26 | 27 | 1. [Setup](#setup) 28 | 2. [Motivation](#motivation) 29 | 3. [Example](#example) 30 | 31 | 32 | 33 | ## Setup 34 | 35 | ### Firebase environment variables 36 | 37 | To set up this plugin you will need three sensitive environment variables in order to authenticate with Firebase. These are: 38 | 39 | 1. [Firebase Service Account](https://firebase.google.com/docs/app-distribution/authenticate-service-account) 40 | 2. [Firebase User ID](https://firebase.google.com/docs/auth/web/manage-users) 41 | 3. [Firebase Configurations](https://support.google.com/firebase/answer/7015592?hl=en#zippy=%2Cin-this-article) 42 | 43 | For more information about Firebase you can read the documentation [here](https://firebase.google.com/docs/auth/web/start) 44 | 45 | It's recommended to place these values in a `.env` file. For clarity, the Firebase User ID is often abbreviated to UID, as you will find below. The plugin accepts 46 | 47 | - Service Account: JSON 48 | - UID: string 49 | - Firebase Configurations: JSON 50 | 51 | you don't need to place quotes around these environment variables. 52 | 53 | ### Attaching playright-firebase as a fixture to Playwright 54 | 55 | Playwright is based on fixtures. You have likely already used them within Playwright, they are the `{ page }` object that is passed in to `test`. More information on them [here](https://playwright.dev/docs/test-fixtures). In the very same way, we are able to add our own fixture, which we call `auth` to the tests. To do so, we need to create a setup file that will automatically run before all other tests. We will call this `auth.setup.ts` 56 | 57 | ```ts 58 | // auth.setup.ts 59 | import playwrightFirebasePlugin from '@nearform/playwright-firebase' 60 | import { test as base } from '@playwright/test' 61 | 62 | const serviceAccount = JSON.parse(process.env.SERVICE_ACCOUNT!) 63 | const uid = process.env.REACT_APP_UID! 64 | const options = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG!) 65 | export const test = playwrightFirebasePlugin(serviceAccount, options, uid, base) 66 | ``` 67 | 68 | Above we import the test directly from `@playwright/test` as `base` and then export a new `test` object which is identical to `base` with the addition of a fixture called `auth`. An important note is that we should now import `test` from this file instead of `@playwright/test`. 69 | 70 | ```ts 71 | //example.spec.ts 72 | import { expect } from '@playwright/test' 73 | import { test } from '../auth.setup' // <----- here we import test from our auth.setup.ts. 74 | import { Page } from '@playwright/test' 75 | 76 | // We now access auth in exactly the same method as we do page. 77 | test('has title', async ({ page, auth }: { page: Page; auth: any }) => { 78 | await page.goto('/', { waitUntil: 'networkidle' }) 79 | await auth.login(page) // <-- we need to pass in the page object here. 80 | 81 | const txt = await page.getByText('Welcome! You are now logged in') 82 | await expect(txt).toBeVisible() 83 | 84 | await auth.logout(page) 85 | 86 | await expect(txt).not.toBeVisible() 87 | }) 88 | ``` 89 | 90 | It's recommended to use `await` for your `expect` assertions after logging in/out, as the Firebase authentication is likely tied to states that require re-rendering of your application. 91 | 92 | ### TypeScript 93 | 94 | If you're using Typescript, one small addition you'll need to make is to add the type `Credentials` to your `playwright.config.ts` such that 95 | 96 | ```ts 97 | import { Credentials } from '@nearform/playwright-firebase' 98 | 99 | export default defineConfig({ 100 | ... 101 | }) 102 | ``` 103 | 104 | 105 | 106 | ## Motivation 107 | 108 | This package is built as a plugin for Playwright testing for the use-case of Firebase authentication. There are two methods of automating a login procedure in Playwright tests: 109 | 110 | 1. As a normal user would: inserting a real username and password into the intended fields. 111 | 2. Authenticating via the Firebase SDK directly 112 | 113 | This plugin was developed with the 2nd method in mind as it is 114 | 115 | - Provider agnostic: Does not need to know the specifics of the authentication provider 116 | - A faster way of logging in, so you can focus on testing 117 | - Better security than using a username and password. 118 | 119 | 120 | 121 | ## Example 122 | 123 | Within this repo we have an `example/` folder that contains a sample React application for authenticating with the Google Provider. You'll need to setup the Firebase environment variables as described above in the setup section, but the rest is taken care of. 124 | 125 | 1. Clone this repository 126 | 2. `npm i` 127 | 3. `cd ./example` 128 | 4. `npm i` 129 | 5. `npm run start` 130 | 131 | At this point, you should see a web server running on `localhost:3000`. If your screen is blank, check the console on your browser for any error messages. It's likely that you haven't place the `.env` file in the right location, or that you haven't filled it in correctly. 132 | 133 | 6. Make a `.env` file within `./example`, copy and paste over the variable names from `.env.sample` and populate them with your real Firebase environment variables 134 | 7. Run `npx playwright test` 135 | 136 | [![banner](https://raw.githubusercontent.com/nearform/.github/refs/heads/master/assets/os-banner-green.svg)](https://www.nearform.com/contact/?utm_source=open-source&utm_medium=banner&utm_campaign=os-project-pages) 137 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; -------------------------------------------------------------------------------- /example/.env.sample: -------------------------------------------------------------------------------- 1 | REACT_APP_FIREBASE_CONFIG= 2 | REACT_APP_UID= 3 | SERVICE_ACCOUNT= 4 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | /test-results/ 26 | /playwright-report/ 27 | /playwright/.cache/ 28 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "private": true, 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^5.17.0", 8 | "@testing-library/react": "^13.4.0", 9 | "@testing-library/user-event": "^13.5.0", 10 | "@types/jest": "^27.5.2", 11 | "@types/node": "^16.18.59", 12 | "@types/react": "^18.2.29", 13 | "@types/react-dom": "^18.2.14", 14 | "dotenv": "^16.3.1", 15 | "playwright": "^1.39.0", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "react-scripts": "5.0.1", 19 | "typescript": "^4.9.5", 20 | "web-vitals": "^2.1.4" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test", 26 | "eject": "react-scripts eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": [ 30 | "react-app", 31 | "react-app/jest" 32 | ] 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | }, 46 | "devDependencies": { 47 | "@playwright/test": "^1.39.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /example/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | import { Credentials } from '@nearform/playwright-firebase' 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './test-examples/', 14 | testIgnore: '*.setup.ts', 15 | /* Run tests in files in parallel */ 16 | fullyParallel: true, 17 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 18 | forbidOnly: !!process.env.CI, 19 | /* Retry on CI only */ 20 | retries: process.env.CI ? 2 : 0, 21 | /* Opt out of parallel tests on CI. */ 22 | workers: process.env.CI ? 1 : undefined, 23 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 24 | reporter: 'html', 25 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 26 | use: { 27 | /* Base URL to use in actions like `await page.goto('/')`. */ 28 | baseURL: 'http://127.0.0.1:3000', 29 | video: 'on', 30 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 31 | trace: 'on-first-retry' 32 | }, 33 | 34 | /* Configure projects for major browsers */ 35 | projects: [ 36 | { 37 | name: 'chromium', 38 | use: { ...devices['Desktop Chrome'] } 39 | }, 40 | 41 | { 42 | name: 'firefox', 43 | use: { ...devices['Desktop Firefox'] } 44 | }, 45 | 46 | { 47 | name: 'webkit', 48 | use: { ...devices['Desktop Safari'] } 49 | } 50 | ], 51 | webServer: { 52 | command: 'npm run start', 53 | url: 'http://127.0.0.1:3000', 54 | reuseExistingServer: !process.env.CI 55 | } 56 | }) 57 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/playwright-firebase/78cbcbcdf7a2cb8a8d0081649baa9d61023a781c/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/playwright-firebase/78cbcbcdf7a2cb8a8d0081649baa9d61023a781c/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/playwright-firebase/78cbcbcdf7a2cb8a8d0081649baa9d61023a781c/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | h1 { 6 | text-align: center; 7 | } 8 | 9 | .user-input { 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | background-color: #2aabcf; 14 | padding: 10px; 15 | margin-left: 50px; 16 | margin-right: 50px; 17 | border: solid 4px black; 18 | border-radius: 20px; 19 | } 20 | 21 | button { 22 | height: 50px; 23 | width: 100px; 24 | background-color: white; 25 | border-radius: 5px; 26 | font-size: 20px; 27 | } 28 | .username { 29 | color: white; 30 | font-size: 20px; 31 | } 32 | 33 | .App-logo { 34 | height: 40vmin; 35 | pointer-events: none; 36 | } 37 | 38 | @media (prefers-reduced-motion: no-preference) { 39 | .App-logo { 40 | animation: App-logo-spin infinite 20s linear; 41 | } 42 | } 43 | 44 | .App-header { 45 | background-color: #282c34; 46 | min-height: 100vh; 47 | display: flex; 48 | flex-direction: column; 49 | align-items: center; 50 | justify-content: center; 51 | font-size: calc(10px + 2vmin); 52 | color: white; 53 | } 54 | 55 | .App-link { 56 | color: #61dafb; 57 | } 58 | 59 | @keyframes App-logo-spin { 60 | from { 61 | transform: rotate(0deg); 62 | } 63 | to { 64 | transform: rotate(360deg); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /example/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import useAuth from './hooks/useAuth' 2 | import './App.css' 3 | function App() { 4 | const { user, login, logout } = useAuth() 5 | const buttonText = !user ? 'Log in' : 'Log out' 6 | const handleLoginOut = () => { 7 | if (user) { 8 | logout() 9 | return 10 | } 11 | login() 12 | } 13 | return ( 14 | <> 15 |

Welcome!

16 |

17 | This is an example React application to help you see the{' '} 18 | 19 | @nearform/playwright-firebase 20 | {' '} 21 | plugin in action 22 |

23 |

Pre-requisites

24 |

25 | If you're unfamiliar with Firebase please read{' '} 26 | 27 | the documentation 28 | {' '} 29 | on using Firebase SDK. You can find additional information on the README{' '} 30 | here.{' '} 31 |
32 |
33 | You'll need to populate you .env file with your 34 |

59 | * Follow the instructions on the UID related to navigating to the 60 | Firebase console. 61 |

62 |

63 | Below we have some text that either shows the users displayed name, or 64 | "Logged out" depending on whether you've logged in with the button below 65 |

66 |
67 |

Try logging in! It will open a popup to sign in with Google.

68 |
69 |

70 | {user ? user.displayName : 'You are logged out'} 71 |

72 | 73 |
74 |
75 | 76 | ) 77 | } 78 | 79 | export default App 80 | -------------------------------------------------------------------------------- /example/src/hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { AuthContext } from '../provider/AuthProvider' 3 | 4 | export default function useAuth() { 5 | return useContext(AuthContext) 6 | } 7 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 5; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import './index.css' 4 | import App from './App' 5 | import reportWebVitals from './reportWebVitals' 6 | import { AuthProvider } from './provider/AuthProvider' 7 | 8 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) 9 | root.render( 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | 17 | // If you want to start measuring performance in your app, pass a function 18 | // to log results (for example: reportWebVitals(console.log)) 19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 20 | reportWebVitals() 21 | -------------------------------------------------------------------------------- /example/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/src/provider/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { initializeApp } from 'firebase/app' 3 | import { 4 | signInWithPopup, 5 | GoogleAuthProvider, 6 | getAuth, 7 | browserSessionPersistence, 8 | setPersistence, 9 | User, 10 | UserCredential 11 | } from 'firebase/auth' 12 | import { useState, useEffect, useRef } from 'react' 13 | import { useMemo } from 'react' 14 | 15 | type AuthContextType = { 16 | user: User | null 17 | login: () => Promise 18 | logout: () => void 19 | } 20 | 21 | export const AuthContext = React.createContext({ 22 | user: null, 23 | login: () => Promise.resolve(undefined), 24 | logout: () => {} 25 | }) 26 | 27 | const provider = new GoogleAuthProvider() 28 | initializeApp(JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG!)) 29 | 30 | export function AuthProvider({ children }: { children: any }): JSX.Element { 31 | const authRef = useRef(getAuth()) 32 | const [user, setUser] = useState(() => authRef.current.currentUser) 33 | 34 | useEffect( 35 | () => 36 | authRef.current.onAuthStateChanged(async user => { 37 | if (user) { 38 | setUser(user) 39 | } else { 40 | setUser(null) 41 | } 42 | }), 43 | [] 44 | ) 45 | let value = useMemo( 46 | () => ({ 47 | user, 48 | login: async () => { 49 | await setPersistence(getAuth(), browserSessionPersistence) 50 | return signInWithPopup(getAuth(), provider) 51 | }, 52 | logout: async () => { 53 | await getAuth().signOut() 54 | setUser(null) 55 | } 56 | }), 57 | [user] 58 | ) 59 | return {children} 60 | } 61 | -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /example/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /example/test-examples/auth.setup.ts: -------------------------------------------------------------------------------- 1 | import playwrightFirebasePlugin from '../../index' 2 | import { test as base } from '@playwright/test' 3 | import dotenv from 'dotenv' 4 | dotenv.config({ path: './.env' }) 5 | const serviceAccount = JSON.parse(process.env.SERVICE_ACCOUNT!) 6 | const uid = process.env.REACT_APP_UID! 7 | const options = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG!) 8 | export const test = playwrightFirebasePlugin(serviceAccount, options, uid, base) 9 | -------------------------------------------------------------------------------- /example/test-examples/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test' 2 | import { test } from './auth.setup' 3 | import { Page } from '@playwright/test' 4 | test('has title', async ({ page, auth }: { page: Page; auth: any }) => { 5 | await page.goto('/', { waitUntil: 'networkidle' }) 6 | expect(page.getByText('Welcome!')).toBeVisible() 7 | await expect(page.getByText('You are logged out')).toBeVisible() 8 | 9 | await auth.login(page) 10 | 11 | await expect(page.getByText('Log out')).toBeVisible() //this must be awaited as it takes some time to authorise and re-render. 12 | 13 | //Add down below your google accounts display name to test this even further. 14 | //await expect(page.getByText('')).toBeVisible() 15 | 16 | await auth.logout(page) 17 | await expect(page.getByText('You are logged out')).toBeVisible() 18 | }) 19 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import type { ServiceAccount } from 'firebase-admin' 2 | import type { FirebaseOptions } from 'firebase/app' 3 | import type { 4 | TestType, 5 | PlaywrightTestArgs, 6 | PlaywrightTestOptions, 7 | PlaywrightWorkerArgs, 8 | PlaywrightWorkerOptions 9 | } from '@playwright/test' 10 | import { Authentication } from './plugin/Authentication.js' 11 | 12 | export type Credentials = { 13 | auth: Authentication 14 | UID: string 15 | serviceAccount: ServiceAccount 16 | options: FirebaseOptions 17 | } 18 | 19 | export interface AuthenticationParams { 20 | UID: string 21 | options: FirebaseOptions 22 | serviceAccount: ServiceAccount 23 | version: string 24 | } 25 | 26 | export default function playwrightFirebasePlugin( 27 | serviceAccount: ServiceAccount, 28 | options: FirebaseOptions, 29 | UID: string, 30 | base: TestType< 31 | PlaywrightTestArgs & PlaywrightTestOptions, 32 | PlaywrightWorkerArgs & PlaywrightWorkerOptions 33 | >, 34 | version = '10.5.0' 35 | ) { 36 | return base.extend<{ auth: Authentication } & AuthenticationParams>({ 37 | UID: [UID, { option: true }], 38 | serviceAccount: [serviceAccount, { option: true }], 39 | options: [options, { option: true }], 40 | version: [version, { option: true }], 41 | auth: ( 42 | { UID, options, serviceAccount, version }: AuthenticationParams, 43 | use: (authentication: Authentication) => Promise 44 | ) => use(new Authentication(UID, options, serviceAccount, version)) 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest' 2 | 3 | const config: Config = { 4 | verbose: true, 5 | testMatch: ['**/tests/**.(test).@(ts)'], 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1' 8 | } 9 | } 10 | 11 | export default config 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nearform/playwright-firebase", 3 | "version": "1.2.9", 4 | "description": "Plugin to allow for Firebase authentication in Playwright tests", 5 | "main": "dist/index.js", 6 | "exports": { 7 | ".": { 8 | "types": { 9 | "import": "./dist/index.d.ts", 10 | "require": "./cjs/index.d.ts" 11 | }, 12 | "import": "./dist/index.js", 13 | "require": "./cjs/index.js", 14 | "default": "./cjs/index.js" 15 | }, 16 | "./package.json": "./package.json" 17 | }, 18 | "type": "module", 19 | "files": [ 20 | "dist/**/*", 21 | "cjs/**/*" 22 | ], 23 | "types": "./dist/index.d.ts", 24 | "scripts": { 25 | "lint": "eslint \"*.{ts,tsx}\"", 26 | "prepare": "husky && npm run build", 27 | "test": "jest", 28 | "test:e2e": "cd tests/helpers/test-site && npm ci && npx playwright test", 29 | "test:pw": "npx playwright test", 30 | "pw": "npx playwright install --with-deps", 31 | "build": "npm run build:cjs; npm run build:esm", 32 | "build:cjs": "tsc --module commonjs --outDir cjs; echo '{\"type\":\"commonjs\"}' > cjs/package.json", 33 | "build:esm": "tsc --module esnext --outDir dist" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/nearform/playwright-firebase.git" 38 | }, 39 | "keywords": [ 40 | "playwright", 41 | "firebase", 42 | "testing", 43 | "plugin" 44 | ], 45 | "license": "Apache-2.0", 46 | "bugs": { 47 | "url": "https://github.com/nearform/playwright-firebase/issues" 48 | }, 49 | "homepage": "https://github.com/nearform/playwright-firebase#readme", 50 | "devDependencies": { 51 | "@babel/core": "^7.21.8", 52 | "@babel/preset-env": "^7.21.5", 53 | "@babel/preset-typescript": "^7.22.5", 54 | "@commitlint/cli": "^19.0.3", 55 | "@commitlint/config-conventional": "^19.0.3", 56 | "@jest/globals": "^29.5.0", 57 | "@playwright/test": "^1.37.1", 58 | "@types/node": "^22.1.0", 59 | "@typescript-eslint/eslint-plugin": "^7.0.0", 60 | "@typescript-eslint/parser": "^6.0.0", 61 | "babel-jest": "^29.5.0", 62 | "eslint": "^8.47.0", 63 | "eslint-config-prettier": "^10.0.1", 64 | "eslint-plugin-prettier": "^5.0.0", 65 | "firebase": "^11.0.1", 66 | "firebase-admin": "^13.0.0", 67 | "husky": "^9.0.11", 68 | "jest": "^29.5.0", 69 | "lint-staged": "^16.0.0", 70 | "playwright": "^1.37.1", 71 | "prettier": "^3.0.1", 72 | "ts-jest": "^29.1.1", 73 | "ts-node": "^10.9.1", 74 | "typescript": "^5.2.2" 75 | }, 76 | "peerDependencies": { 77 | "firebase-admin": "^13.0.0" 78 | }, 79 | "lint-staged": { 80 | "*.{js,jsx}": "eslint --cache --fix" 81 | }, 82 | "dependencies": { 83 | "dotenv": "^16.0.3" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /plugin/Authentication.ts: -------------------------------------------------------------------------------- 1 | import type { ServiceAccount } from 'firebase-admin' 2 | import type { FirebaseApp, FirebaseOptions } from 'firebase/app' 3 | import type { Auth, User } from 'firebase/auth' 4 | import type { Page } from '@playwright/test' 5 | 6 | import { addFirebaseScript, getToken } from './auth.setup.js' 7 | 8 | // Since these are declared in browser modules, it's hard to understand what the types should be. 9 | // As such we're defining what shape we're expecting. 10 | declare global { 11 | type Firebase = { 12 | getApps: () => FirebaseApp[] 13 | initializeApp: (config: FirebaseOptions) => FirebaseApp 14 | } 15 | 16 | type FirebaseAuth = { 17 | getAuth: (app?: FirebaseApp) => Auth 18 | signInWithCustomToken: (auth: Auth, token: string) => Promise 19 | } 20 | 21 | interface Window { 22 | firebase: Firebase 23 | Auth: FirebaseAuth 24 | } 25 | } 26 | 27 | export const errors = { 28 | FIREBASE_VERSION_ERROR: 29 | 'Bad Request: Please ensure your version number is correct.', 30 | FIREBASE_MODULE_LOAD_ERROR: 31 | 'Failed to load firebase module script. Please check your network settings', 32 | FIREBASE_INIT_ERROR: 'Error initialising and signing in' 33 | } 34 | 35 | export class Authentication { 36 | userSet = false 37 | 38 | constructor( 39 | private readonly UID: string, 40 | private readonly options: FirebaseOptions, 41 | private readonly serviceAccount: ServiceAccount, 42 | private version: string 43 | ) {} 44 | 45 | async login(page: Page) { 46 | if (this.userSet) { 47 | console.info('User already authenticated') 48 | return 49 | } 50 | 51 | const response = await fetch( 52 | `https://www.gstatic.com/firebasejs/${this.version}/firebase-auth.js` 53 | ) 54 | if (response.status.toString().startsWith('4')) { 55 | throw new Error( 56 | 'Bad Request: Please ensure your version number is correct.' 57 | ) 58 | } 59 | const token: string = await getToken(this.serviceAccount, this.UID) 60 | await addFirebaseScript(page, this.version) 61 | 62 | const firebaseModuleLoaded = await page 63 | .mainFrame() 64 | .waitForFunction('window.firebase !== undefined', {}) 65 | 66 | if (!firebaseModuleLoaded) { 67 | throw new Error(errors.FIREBASE_MODULE_LOAD_ERROR) 68 | } 69 | 70 | try { 71 | await page.evaluate( 72 | async ({ token, config }) => { 73 | const apps = window.firebase.getApps() 74 | const app = apps.length 75 | ? apps[0] 76 | : window.firebase.initializeApp(config) 77 | const auth = window.Auth.getAuth(app) 78 | await window.Auth.signInWithCustomToken(auth, token) 79 | }, 80 | { token, config: this.options } 81 | ) 82 | this.userSet = true 83 | } catch { 84 | throw new Error(errors.FIREBASE_INIT_ERROR) 85 | } 86 | } 87 | 88 | async logout(page: Page) { 89 | await addFirebaseScript(page, this.version) 90 | try { 91 | await page.evaluate(async () => { 92 | const auth = window.Auth.getAuth() 93 | await auth.signOut() 94 | }) 95 | this.userSet = false 96 | } catch { 97 | console.log('Error signing out') 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /plugin/auth.setup.ts: -------------------------------------------------------------------------------- 1 | import admin, { ServiceAccount } from 'firebase-admin' 2 | import type { Page } from '@playwright/test' 3 | 4 | /** 5 | * Sets up Admin app. Creates a custom token with the admin app, and 6 | * use that in the browser to authenticate 7 | */ 8 | const setupAdmin = (serviceAccount: ServiceAccount): void => { 9 | try { 10 | admin.initializeApp({ credential: admin.credential.cert(serviceAccount) }) 11 | } catch (err) { 12 | throw Error(`Cannot initialise Firebase Admin: ${err}`) 13 | } 14 | } 15 | 16 | const getToken = async (serviceAccount: ServiceAccount, uid: string) => { 17 | if (admin.apps?.length === 0) { 18 | setupAdmin(serviceAccount) 19 | } 20 | 21 | const token: string = await admin.auth().createCustomToken(uid) 22 | return token 23 | } 24 | 25 | async function addFirebaseScript(page: Page, version: string) { 26 | await page.addScriptTag({ 27 | url: `https://www.gstatic.com/firebasejs/${version}/firebase-auth.js`, 28 | type: 'module' 29 | }) 30 | await page.addScriptTag({ 31 | url: `https://www.gstatic.com/firebasejs/${version}/firebase-app.js`, 32 | type: 'module' 33 | }) 34 | 35 | // Note: Will resolve as soon as the content is injected into the frame 36 | // and will not wait for the scripts to be loaded in this case. 37 | // The above caching may or maynot work based on network conditions 38 | await page.addScriptTag({ 39 | content: ` 40 | import * as Auth from 'https://www.gstatic.com/firebasejs/${version}/firebase-auth.js'; 41 | import * as firebase from 'https://www.gstatic.com/firebasejs/${version}/firebase-app.js'; 42 | window.Auth = Auth; 43 | window.firebase = firebase; 44 | `, 45 | type: 'module' 46 | }) 47 | } 48 | 49 | export { getToken, addFirebaseScript } 50 | -------------------------------------------------------------------------------- /tests/Authentication.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | jest, 3 | describe, 4 | expect, 5 | test, 6 | beforeEach, 7 | afterEach 8 | } from '@jest/globals' 9 | import { Page } from '@playwright/test' 10 | import * as authSetup from '../plugin/auth.setup' 11 | 12 | import { Authentication, errors } from '../plugin/Authentication' 13 | 14 | const TEST_UID = 'test-uid' 15 | const TEST_OPTIONS = {} 16 | const TEST_SERVICE_ACCOUNT = {} 17 | const TEST_VERSION = 'ver' 18 | const mockedStatusCode = jest.fn() 19 | 20 | const mockedEvaluate = jest.fn() 21 | const mockedAddScriptTag = jest.fn() 22 | const mockedMainFrameFuncs = { 23 | waitForFunction: jest.fn() 24 | } 25 | const mockedMainFrame = jest.fn(() => mockedMainFrameFuncs) 26 | 27 | const pageMock: Page = { 28 | evaluate: mockedEvaluate, 29 | addScriptTag: mockedAddScriptTag, 30 | mainFrame: mockedMainFrame 31 | } as unknown as Page 32 | 33 | const generateAuth = () => { 34 | return new Authentication( 35 | TEST_UID, 36 | TEST_OPTIONS, 37 | TEST_SERVICE_ACCOUNT, 38 | TEST_VERSION 39 | ) 40 | } 41 | 42 | const mockedResponse = () => 43 | Promise.resolve({ 44 | status: mockedStatusCode() 45 | } as Response) 46 | 47 | describe('Authentication Class tests', () => { 48 | beforeEach(() => { 49 | jest.spyOn(authSetup, 'getToken').mockReturnValue(Promise.resolve('hello')) 50 | jest.spyOn(global, 'fetch').mockImplementation(mockedResponse) 51 | mockedMainFrameFuncs.waitForFunction.mockImplementation(() => true) 52 | mockedStatusCode.mockReturnValue(200) 53 | }) 54 | afterEach(() => { 55 | jest.clearAllMocks() 56 | }) 57 | test('Authentication class initialised w/ log in/out functions', () => { 58 | const Auth = generateAuth() 59 | expect(Auth).toBeDefined() 60 | expect(Auth.login).toBeDefined() 61 | expect(Auth.logout).toBeDefined() 62 | }) 63 | test('Logging in calls the evaluate function of the page + addScriptTag', async () => { 64 | const Auth = generateAuth() 65 | await Auth.login(pageMock) 66 | expect(mockedEvaluate).toHaveBeenCalled() 67 | expect(mockedAddScriptTag).toHaveBeenCalledTimes(3) 68 | }) 69 | test('Bad version number throws', async () => { 70 | mockedStatusCode.mockReturnValue(400) 71 | const Auth = generateAuth() 72 | expect(async () => { 73 | await Auth.login(pageMock) 74 | }).rejects.toThrowError() 75 | }) 76 | test('If user already exists, dont log in', async () => { 77 | const Auth = generateAuth() 78 | Auth.userSet = true 79 | await Auth.login(pageMock) 80 | expect(mockedEvaluate).not.toHaveBeenCalled() 81 | }) 82 | test('Logging out resets user', async () => { 83 | const Auth = generateAuth() 84 | Auth.userSet = true 85 | await Auth.logout(pageMock) 86 | expect(pageMock.evaluate).toHaveBeenCalled() 87 | expect(Auth.userSet).toBe(false) 88 | }) 89 | test("Fail when the firebase script doesn't load", async () => { 90 | mockedMainFrameFuncs.waitForFunction.mockImplementation(() => false) 91 | const Auth = generateAuth() 92 | Auth.userSet = false 93 | const error = new Error(errors.FIREBASE_MODULE_LOAD_ERROR) 94 | expect(Auth.login(pageMock)).rejects.toThrow(error) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /tests/authsetup.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, expect, test, afterEach } from '@jest/globals' 2 | import { ServiceAccount } from 'firebase-admin' 3 | import { getToken } from '../plugin/auth.setup' 4 | import admin from 'firebase-admin' 5 | import { app } from 'firebase-admin' 6 | const TEST_UID = 'uid' 7 | const TEST_TOKEN = 'token' 8 | const mockServiceAccount: ServiceAccount = 9 | jest.fn() as unknown as ServiceAccount 10 | const mockApp = {} as unknown as app.App 11 | 12 | jest.mock('firebase-admin', () => { 13 | return { 14 | auth: jest.fn(() => { 15 | return { createCustomToken: jest.fn(() => Promise.resolve(TEST_TOKEN)) } 16 | }), 17 | initializeApp: jest.fn(), 18 | credential: { 19 | cert: jest.fn() 20 | }, 21 | 22 | apps: [], 23 | app: jest.fn(() => { 24 | return { delete: jest.fn() } 25 | }) 26 | } 27 | }) 28 | describe('auth.setup tests', () => { 29 | describe('getToken', () => { 30 | jest.spyOn(admin, 'initializeApp').mockImplementation(() => { 31 | admin.apps.push(mockApp) 32 | return mockApp 33 | }) 34 | jest.spyOn(admin, 'app').mockImplementation(() => { 35 | return { 36 | delete: jest.fn(() => admin.apps.pop()) 37 | } as unknown as app.App 38 | }) 39 | afterEach(() => { 40 | jest.clearAllMocks() 41 | admin.app().delete() 42 | }) 43 | 44 | test('Calls the initializeApp function', async () => { 45 | expect(admin.apps.length).toBe(0) 46 | const token = await getToken(mockServiceAccount, TEST_UID) 47 | expect(admin.initializeApp).toHaveBeenCalled() 48 | expect(admin.apps.length).toBe(1) 49 | expect(token).toBe(TEST_TOKEN) 50 | }) 51 | test('If app is already present', async () => { 52 | expect(admin.apps.length).toBe(0) 53 | admin.apps.push(mockApp) 54 | await getToken(mockServiceAccount, TEST_UID) 55 | expect(admin.initializeApp).not.toHaveBeenCalled() 56 | }) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "outDir": "./dist", 7 | "removeComments": false, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "sourceMap": true, 12 | "skipLibCheck": true, 13 | "declaration": true 14 | }, 15 | "exclude": ["node_modules", "playwright.config.ts", "tests", "example"], 16 | "include": ["./index.ts", "./plugin"] 17 | } 18 | --------------------------------------------------------------------------------