├── .editorconfig ├── .github ├── dependabot.yml ├── scripts │ └── start.js └── workflows │ ├── release.yml │ ├── test.yml │ └── update.yml ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── __mocks__ └── webextension-polyfill.ts ├── background ├── index.html └── index.ts ├── content-script ├── component.test.tsx ├── component.tsx ├── index.css └── index.tsx ├── e2e └── test.e2e.ts ├── package-lock.json ├── package.json ├── popup ├── component.test.tsx ├── component.tsx ├── index.html ├── index.tsx └── style.css ├── postcss.config.cjs ├── public ├── icon-128x128.png ├── icon-144x144.png ├── icon-152x152.png ├── icon-72x72.png ├── icon-96x96.png ├── manifest.json └── manifestv2.json ├── tailwind.config.cjs ├── tsconfig.json ├── tsconfig.node.json ├── vite.chrome.config.ts ├── vite.config.ts ├── vite.content.config.ts ├── wdio.conf.ts ├── wdio.e2e.conf.ts └── wdio.ui.conf.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | 12 | end_of_line = lf 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "11:00" 8 | open-pull-requests-limit: 10 9 | versioning-strategy: increase-if-necessary 10 | -------------------------------------------------------------------------------- /.github/scripts/start.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import path from 'node:path' 4 | import url from 'node:url' 5 | import fs from 'node:fs/promises' 6 | 7 | import { remote } from 'webdriverio' 8 | 9 | import pkg from '../../package.json' assert { type: 'json' } 10 | 11 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) 12 | 13 | /** 14 | * start WebDriver session with extension installed 15 | */ 16 | async function startBrowser (browserName) { 17 | const capabilities = browserName === 'chrome' 18 | ? { 19 | browserName, 20 | 'goog:chromeOptions': { 21 | args: [`--load-extension=${path.join(__dirname, '..', '..', 'dist')}`] 22 | } 23 | } 24 | : { browserName } 25 | const browser = await remote({ 26 | // logLevel: 'error', 27 | capabilities 28 | }) 29 | 30 | if (browserName === 'firefox') { 31 | const extension = await fs.readFile(path.join(__dirname, '..', '..', `web-extension-firefox-v${pkg.version}.xpi`)) 32 | await browser.installAddOn(extension.toString('base64'), true) 33 | } 34 | 35 | await browser.url('https://github.com/stateful/web-extension-starter-kit') 36 | } 37 | 38 | const browserName = process.argv.slice(2).pop() || 'chrome' 39 | console.log(`Run web extension in ${browserName}...`) 40 | await startBrowser(browserName) 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 'Release' 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | releaseType: 7 | description: "Release Type" 8 | required: true 9 | type: choice 10 | default: "patch" 11 | options: 12 | - patch 13 | - minor 14 | - major 15 | preRelease: 16 | description: If latest release was a pre-release (e.g. X.X.X-alpha.0) and you want to push another one, pick "yes" 17 | required: true 18 | type: choice 19 | default: "no" 20 | options: 21 | - "yes" 22 | - "no" 23 | 24 | env: 25 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 26 | 27 | jobs: 28 | release: 29 | strategy: 30 | fail-fast: false 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Check out Git repository 34 | uses: actions/checkout@v3 35 | with: 36 | fetch-depth: 0 37 | - name: Setup Git 38 | run: | 39 | git config --global user.name "stateful-wombot" 40 | git config --global user.email "christian+github-bot@stateful.com" 41 | - name: Install 42 | run: npm ci 43 | - name: Bundle 44 | run: npm run bundle 45 | - name: Test 46 | run: npm test 47 | env: 48 | NODE_ENV: production 49 | - name: Prepare Release 50 | run: | 51 | git log $(git describe --tags --abbrev=0)..HEAD --oneline &> ${{ github.workspace }}-CHANGELOG.txt 52 | cat ${{ github.workspace }}-CHANGELOG.txt 53 | rm ./*.zip ./*.crx 54 | - name: Release 55 | run: npx release-it ${{github.event.inputs.releaseType}} --no-npm --no-github.release --ci --npm.skipChecks --no-git.requireCleanWorkingDir 56 | env: 57 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | if: ${{ github.event.inputs.preRelease == 'no' }} 60 | - name: Set Release Version Environment Variable 61 | run: echo "WEB_EXTENSION_VERSION=$(cat package.json | jq .version | cut -d "\"" -f 2)" >> $GITHUB_ENV 62 | - name: GitHub Release 63 | uses: ncipollo/release-action@v1 64 | with: 65 | artifacts: "./*.crx,./*.zip" 66 | bodyFile: ${{ github.workspace }}-CHANGELOG.txt 67 | tag: v${{ env.WEB_EXTENSION_VERSION }} 68 | prerelease: ${{ github.event.inputs.preRelease == 'yes' }} 69 | - uses: actions/upload-artifact@v3 70 | with: 71 | name: extension-files 72 | path: | 73 | ./*.crx 74 | ./*.zip 75 | - name: Submit to Mozilla 76 | working-directory: ./dist 77 | run: npx web-ext-submit@7 78 | env: 79 | WEB_EXT_API_KEY: ${{ secrets.WEB_EXT_API_KEY }} 80 | WEB_EXT_API_SECRET: ${{ secrets.WEB_EXT_API_SECRET }} 81 | - name: Submit to Google 82 | working-directory: ./dist 83 | run: npx chrome-webstore-upload-cli@2 upload --auto-publish 84 | env: 85 | EXTENSION_ID: ${{ secrets.EXTENSION_ID }} 86 | CLIENT_ID: ${{ secrets.CLIENT_ID }} 87 | CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }} 88 | REFRESH_TOKEN: ${{ secrets.REFRESH_TOKEN }} 89 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 'Test' 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out Git repository 13 | uses: actions/checkout@v2 14 | - name: Install 15 | run: npm ci 16 | - name: Bundle 17 | run: npm run bundle 18 | - name: Test 19 | run: npm run test 20 | - uses: actions/upload-artifact@v3 21 | with: 22 | name: extension-files 23 | path: | 24 | ./*.crx 25 | ./*.zip 26 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | # this workflow merges requests from Dependabot if tests are passing 2 | # ref https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions 3 | # and https://github.com/dependabot/fetch-metadata 4 | name: Auto-merge 5 | 6 | # `pull_request_target` means this uses code in the base branch, not the PR. 7 | on: pull_request_target 8 | 9 | # Dependabot PRs' tokens have read permissions by default and thus we must enable write permissions. 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | dependencies: 16 | runs-on: ubuntu-latest 17 | if: github.actor == 'dependabot[bot]' 18 | 19 | steps: 20 | - name: Fetch PR metadata 21 | id: metadata 22 | uses: dependabot/fetch-metadata@v1.3.5 23 | with: 24 | github-token: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | - name: Wait for PR CI 27 | # Don't merge updates to GitHub Actions versions automatically. 28 | # (Some repos may wish to limit by version range (major/minor/patch), or scope (dep vs dev-dep), too.) 29 | if: contains(steps.metadata.outputs.package-ecosystem, 'npm') 30 | uses: lewagon/wait-on-check-action@v1.2.0 31 | with: 32 | ref: ${{ github.event.pull_request.head.sha }} 33 | repo-token: ${{ secrets.GITHUB_TOKEN }} 34 | wait-interval: 30 # seconds 35 | running-workflow-name: dependencies # wait for all checks except this one 36 | allowed-conclusions: success # all other checks must pass, being skipped or cancelled is not sufficient 37 | 38 | - name: Auto-merge dependabot PRs 39 | # Don't merge updates to GitHub Actions versions automatically. 40 | # (Some repos may wish to limit by version range (major/minor/patch), or scope (dep vs dev-dep), too.) 41 | if: contains(steps.metadata.outputs.package-ecosystem, 'npm') 42 | env: 43 | PR_URL: ${{ github.event.pull_request.html_url }} 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | # The "auto" flag will only merge once all of the target branch's required checks 46 | # are met. Configure those in the "branch protection" settings for each repo. 47 | run: gh pr merge --auto --squash "$PR_URL" 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | *.crx 5 | *.pem 6 | *.zip 7 | *.xpi 8 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.15.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Your Company 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # My Web Extension 2 | 3 | > This is a starter kit for building cross platform browser extensions. You can use it as a template for your project. It comes with a [Vite](https://vitejs.dev/) + [TailwindCSS](https://tailwindcss.com/) + [WebdriverIO](https://webdriver.io) setup for building and testing extension popup modals, content and background scripts. Read more about building cross platform browser extensions in our [corresponding blog post](https://stateful.com/blog/building-cross-browser-web-extensions). 4 | 5 | A browser web extension that works on Chrome, Firefox and Safari. Download the extension on the marketplaces: 6 | 7 | - Chrome:: https://chrome.google.com/webstore/detail/my-web-extension/lnihnbkolojkaehnkdmpliededkfebkk 8 | - Firefox: https://addons.mozilla.org/en-GB/firefox/addon/my-web-extension/ 9 | - Safari: _(not yet supported, see [`stateful/web-extension-starter-kit#1`](https://github.com/stateful/web-extension-starter-kit/issues/1))_ 10 | 11 | ## Development 12 | ### Setup 13 | 14 | Install dependencies via: 15 | 16 | ```sh 17 | npm install 18 | ``` 19 | 20 | then start a browser with the web extension installed: 21 | 22 | ```sh 23 | # run Chrome 24 | npm run start:chrome 25 | ``` 26 | 27 | or 28 | 29 | ```sh 30 | # run Firefox 31 | npm run start:firefox 32 | ``` 33 | 34 | This will build the extension and start a browser with it being loaded in it. After making changes, Vite automatically will re-compile the files and you can reload the extension to apply them in the browser. 35 | 36 | ### Build 37 | 38 | Bundle the extension by running: 39 | 40 | ```sh 41 | npm run build 42 | ``` 43 | 44 | This script will bundle the extension as `web-extension-chrome-vX.X.X.crx` and `web-extension-firefox-vX.X.X.zip`. The generated files are in `dist/`. You can also grab a version from the [latest test](https://github.com/stateful/web-extension-starter-kit/actions/workflows/test.yml) run on the `main` branch. 45 | 46 | #### Load in Firefox 47 | 48 | To load the extension in Firefox go to `about:debugging#/runtime/this-firefox` or `Firefox > Preferences > Extensions & Themes > Debug Add-ons > Load Temporary Add-on...`. Here locate the `dist/` directory and open `manifestv2.json` 49 | 50 | #### Load in Chrome 51 | 52 | To load the extensions in Google Chrome go to `chrome://extensions/` and click `Load unpacked`. Locate the dist directory and select `manifest.json`. 53 | 54 | ### Test 55 | 56 | This project tests the extension files using component tests and the extension integration via e2e test with WebdriverIO. 57 | 58 | Run unit/component tests: 59 | 60 | ```sh 61 | npm run test:component 62 | ``` 63 | 64 | Run e2e tests: 65 | 66 | ```sh 67 | npm run test:e2e 68 | ``` 69 | 70 | ## Files: 71 | 72 | - content-script - UI files 73 | - background.ts - Background script/Service worker 74 | - index.html - popup UI 75 | 76 | If you have any questions feel free to open an issue. 77 | -------------------------------------------------------------------------------- /__mocks__/webextension-polyfill.ts: -------------------------------------------------------------------------------- 1 | import { fn } from '@wdio/browser-runner' 2 | 3 | export default { 4 | runtime: { 5 | sendMessage: fn().mockResolvedValue({ data: 'Some funny cat fact!' }) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /background/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Web Extension Background 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /background/index.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | type Message = { 4 | action: 'fetch', 5 | value: null 6 | } 7 | 8 | type ResponseCallback = (data: any) => void 9 | 10 | async function handleMessage({action, value}: Message, response: ResponseCallback) { 11 | if (action === 'fetch') { 12 | const result = await fetch('https://meowfacts.herokuapp.com/'); 13 | 14 | const { data } = await result.json(); 15 | 16 | response({ message: 'success', data }); 17 | } else { 18 | response({data: null, error: 'Unknown action'}); 19 | } 20 | } 21 | 22 | // @ts-ignore 23 | browser.runtime.onMessage.addListener((msg, sender, response) => { 24 | handleMessage(msg, response); 25 | return true; 26 | }); 27 | -------------------------------------------------------------------------------- /content-script/component.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { $, expect } from '@wdio/globals' 3 | import { render } from '@testing-library/react' 4 | import browser from 'webextension-polyfill' 5 | 6 | import Component from './component.js' 7 | 8 | describe('Content Script Component Tests', () => { 9 | it('should be able to fetch cat facts', async () => { 10 | render() 11 | await expect($('h1')).toHaveText('Cat Facts!') 12 | 13 | const getCatFactBtn = await $('aria/Get a Cat Fact!') 14 | await getCatFactBtn.click() 15 | 16 | await getCatFactBtn.waitForEnabled() 17 | await expect($('p')).toHaveText('Some funny cat fact!') // WebdriverIO matcher (async) 18 | expect(browser.runtime.sendMessage).toHaveBeenCalledWith({ action: 'fetch' }) // Jest matcher (sync) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /content-script/component.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import browser from 'webextension-polyfill' 3 | 4 | export default () => { 5 | const [fact, setFact] = useState('Click the button to fetch a fact!') 6 | const [loading, setLoading] = useState(false) 7 | 8 | async function handleOnClick() { 9 | setLoading(true) 10 | const {data} = await browser.runtime.sendMessage({ action: 'fetch' }) 11 | setFact(data) 12 | setLoading(false) 13 | } 14 | 15 | return ( 16 |
17 |
18 |

Cat Facts!

19 | 23 |

{fact}

24 |
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /content-script/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | h1 { 7 | @apply text-slate-700 text-xl 8 | } 9 | } 10 | 11 | /* global CSS here */ 12 | a { 13 | text-decoration: dashed; 14 | } 15 | -------------------------------------------------------------------------------- /content-script/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | 4 | import manifest from '../public/manifest.json' assert { type: 'json' } 5 | 6 | import App from './component.jsx' 7 | import './index.css' 8 | 9 | console.debug(`Initiate Web Extension v${manifest.version}`) 10 | 11 | const pluginTagId = 'extension-root' 12 | const existingInstance = document.getElementById(pluginTagId) 13 | if (existingInstance) { 14 | console.debug('existing instance found, removing') 15 | existingInstance.remove() 16 | } 17 | 18 | const component = document.createElement('div') 19 | component.setAttribute('id', pluginTagId) 20 | 21 | // Make sure the element that you want to mount the app to has loaded. You can 22 | // also use `append` or insert the app using another method: 23 | // https://developer.mozilla.org/en-US/docs/Web/API/Element#methods 24 | // 25 | // Also control when the content script is injected from the manifest.json: 26 | // https://developer.chrome.com/docs/extensions/mv3/content_scripts/#run_time 27 | document.body.append(component) 28 | ReactDOM.createRoot(component).render( 29 | 30 | 31 | 32 | ) 33 | -------------------------------------------------------------------------------- /e2e/test.e2e.ts: -------------------------------------------------------------------------------- 1 | import { browser, $$, $, expect } from '@wdio/globals' 2 | import type { Capabilities } from '@wdio/types' 3 | 4 | const isFirefox = (browser.capabilities as Capabilities.Capabilities).browserName === 'firefox' 5 | 6 | describe('Web Extension e2e test', () => { 7 | it('should have injected the component from the content script', async () => { 8 | await browser.url('https://google.com') 9 | await expect($$('#extension-root')).toBeElementsArrayOfSize(1) 10 | }) 11 | 12 | it('can get cat facts', async () => { 13 | const extensionRoot = await $('#extension-root') 14 | const getCatFactBtn = await extensionRoot.$('aria/Get a Cat Fact!') 15 | await getCatFactBtn.click() 16 | await expect(extensionRoot.$('p')).not.toHaveText('Click the button to fetch a fact!') 17 | }) 18 | 19 | if (!isFirefox) { 20 | it('should get cat facts in popup window', async () => { 21 | await browser.openExtensionPopup('My Web Extension') 22 | 23 | const extensionRoot = await $('#extension-root') 24 | const getCatFactBtn = await extensionRoot.$('aria/Get a Cat Fact!') 25 | await getCatFactBtn.click() 26 | await expect(extensionRoot.$('p')).not.toHaveText('Click the button to fetch a fact!') 27 | }) 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-web-extension", 3 | "version": "0.0.0", 4 | "description": "A web extension that works in all browser.", 5 | "author": "Christian Bromann ", 6 | "license": "MIT", 7 | "type": "module", 8 | "private": true, 9 | "scripts": { 10 | "clean": "rimraf dist ./*.zip ./*.crx ./*.pem", 11 | "dev": "vite", 12 | "build:firefox": "NODE_ENV=production run-s clean compile build:firefox:*", 13 | "build:firefox:manifest": "mv dist/manifestv2.json dist/manifest.json", 14 | "build:chrome": "NODE_ENV=production run-s clean compile build:chrome:*", 15 | "build:chrome:script": "vite build --config vite.chrome.config.ts", 16 | "build:chrome:manifest": "rm dist/manifestv2.json", 17 | "bundle": "run-s bundle:*", 18 | "bundle:clean": "run-s clean", 19 | "bundle:firefox": "run-s bundle:firefox:*", 20 | "bundle:firefox:build": "run-s build:firefox", 21 | "bundle:firefox:zip": "web-ext build -s dist/ -a . && mv ./my_web_extension-$npm_package_version.zip ./web-extension-firefox-v$npm_package_version.xpi", 22 | "bundle:chrome": "run-s bundle:chrome:*", 23 | "bundle:chrome:build": "run-s build:chrome", 24 | "bundle:chrome:zip": "zip -r ./web-extension-chrome-v$npm_package_version.zip ./dist", 25 | "bundle:chrome:crx": "crx pack ./dist -o ./web-extension-chrome-v$npm_package_version.crx", 26 | "compile": "run-s compile:*", 27 | "compile:tsc": "tsc", 28 | "compile:vite": "run-p compile:vite:*", 29 | "compile:vite:web": "vite build", 30 | "compile:vite:js": "vite build --config vite.content.config.ts", 31 | "preview": "vite preview", 32 | "start": "run-s start:chrome", 33 | "start:chrome": "run-p start:chrome:*", 34 | "start:chrome:watcher": "npm run watch:chrome", 35 | "start:chrome:startBrowser": "./.github/scripts/start.js chrome", 36 | "start:firefox": "npm-run-all bundle:firefox --parallel start:firefox:*", 37 | "start:firefox:watcher": "npm run watch:firefox", 38 | "start:firefox:startBrowser": "./.github/scripts/start.js firefox", 39 | "test": "run-s test:*", 40 | "test:component": "wdio run ./wdio.ui.conf.ts", 41 | "test:e2e": "wdio run ./wdio.e2e.conf.ts", 42 | "version": "run-s version:*", 43 | "version:update": "npx replace ' \"version\": \"(\\d+).(\\d+).(\\d+)\"' \" \\\"version\\\": \\\"$npm_package_version\\\"\" ./public/*.json", 44 | "version:bundle": "run-s bundle", 45 | "version:git": "git add public", 46 | "watch:chrome": "run-p watch:all:* watch:chrome:script", 47 | "watch:firefox": "run-p watch:chrome:*", 48 | "watch:all:web": "npm run compile:vite:web -- --watch", 49 | "watch:all:js": "npm run compile:vite:js -- --watch", 50 | "watch:chrome:script": "sleep 1 && npm run build:chrome:script -- --watch" 51 | }, 52 | "devDependencies": { 53 | "@testing-library/react": "^14.0.0", 54 | "@types/node": "^20.5.0", 55 | "@types/react": "^18.2.20", 56 | "@types/webextension-polyfill": "^0.10.1", 57 | "@vitejs/plugin-react": "^4.0.4", 58 | "@wdio/browser-runner": "^8.14.6", 59 | "@wdio/cli": "^8.14.6", 60 | "@wdio/firefox-profile-service": "^8.14.0", 61 | "@wdio/mocha-framework": "^8.14.0", 62 | "@wdio/spec-reporter": "^8.14.0", 63 | "autoprefixer": "^10.4.15", 64 | "chrome-launcher": "^1.0.0", 65 | "crx": "^5.0.1", 66 | "npm-run-all": "^4.1.5", 67 | "postcss": "^8.4.27", 68 | "release-it": "^17.0.0", 69 | "replace": "^1.2.2", 70 | "rimraf": "^5.0.1", 71 | "tailwindcss": "^3.3.3", 72 | "ts-node": "^10.9.1", 73 | "typescript": "^5.1.6", 74 | "vite": "^5.0.2", 75 | "web-ext": "^7.6.2", 76 | "webextension-polyfill": "^0.10.0" 77 | }, 78 | "dependencies": { 79 | "react": "^18.2.0", 80 | "react-dom": "^18.2.0" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /popup/component.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { $, expect } from '@wdio/globals' 3 | import { render } from '@testing-library/react' 4 | import browser from 'webextension-polyfill' 5 | 6 | import Component from './component.js' 7 | 8 | describe('Popup Component Tests', () => { 9 | it('should be able to fetch cat facts', async () => { 10 | render() 11 | await expect($('h1')).toHaveText('Cat Facts!') 12 | 13 | const getCatFactBtn = await $('aria/Get a Cat Fact!') 14 | await getCatFactBtn.click() 15 | 16 | await getCatFactBtn.waitForEnabled() 17 | await expect($('p')).toHaveText('Some funny cat fact!') // WebdriverIO matcher (async) 18 | expect(browser.runtime.sendMessage).toHaveBeenCalledWith({ action: 'fetch' }) // Jest matcher (sync) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /popup/component.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import browser from 'webextension-polyfill' 3 | 4 | export default () => { 5 | const [fact, setFact] = useState('Click the button to fetch a fact!') 6 | const [loading, setLoading] = useState(false) 7 | 8 | async function handleOnClick() { 9 | setLoading(true) 10 | const {data} = await browser.runtime.sendMessage({ action: 'fetch' }) 11 | setFact(data) 12 | setLoading(false) 13 | } 14 | 15 | return ( 16 |
17 |

Cat Facts!

18 | 22 |

{fact}

23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Web Extension Frontend 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /popup/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | 4 | import App from './component.jsx' 5 | 6 | const pluginTagId = 'extension-root' 7 | const component = document.getElementById(pluginTagId)! 8 | ReactDOM.createRoot(component).render( 9 | 10 | 11 | 12 | ) 13 | -------------------------------------------------------------------------------- /popup/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | h1 { 7 | @apply text-slate-700 text-xl 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stateful/web-extension-starter-kit/599f745867111dc3832992998c3ae07a75784130/public/icon-128x128.png -------------------------------------------------------------------------------- /public/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stateful/web-extension-starter-kit/599f745867111dc3832992998c3ae07a75784130/public/icon-144x144.png -------------------------------------------------------------------------------- /public/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stateful/web-extension-starter-kit/599f745867111dc3832992998c3ae07a75784130/public/icon-152x152.png -------------------------------------------------------------------------------- /public/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stateful/web-extension-starter-kit/599f745867111dc3832992998c3ae07a75784130/public/icon-72x72.png -------------------------------------------------------------------------------- /public/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stateful/web-extension-starter-kit/599f745867111dc3832992998c3ae07a75784130/public/icon-96x96.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "My Web Extension", 4 | "author": "my@email.com", 5 | "description": "A cross browser web extension.", 6 | "homepage_url": "https://projectUrl.com", 7 | "version": "0.0.0", 8 | "content_scripts": [ 9 | { 10 | "matches": [""], 11 | "run_at": "document_idle", 12 | "js": ["./contentScript/index.js"], 13 | "css": ["./style.css"] 14 | } 15 | ], 16 | "background": { 17 | "service_worker": "./background/background.js" 18 | }, 19 | "action": { 20 | "default_popup": "./popup/index.html", 21 | "default_title": "Open the popup" 22 | }, 23 | "browser_specific_settings": { 24 | "gecko": { 25 | "id": "addon@example.com", 26 | "strict_min_version": "100.0" 27 | } 28 | }, 29 | "permissions": [], 30 | "icons": { 31 | "72": "icon-72x72.png", 32 | "96": "icon-96x96.png", 33 | "128": "icon-128x128.png", 34 | "144": "icon-144x144.png", 35 | "152": "icon-152x152.png" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/manifestv2.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "My Web Extension", 4 | "author": "my@email.com", 5 | "description": "A cross browser web extension.", 6 | "homepage_url": "https://projectUrl.com", 7 | "version": "0.0.0", 8 | "content_scripts": [ 9 | { 10 | "matches": [""], 11 | "run_at": "document_idle", 12 | "js": ["./contentScript/index.js"], 13 | "css": ["./style.css"] 14 | } 15 | ], 16 | "background": { 17 | "page": "./background/index.html", 18 | "persistent": false 19 | }, 20 | "browser_action": { 21 | "default_area": "navbar", 22 | "default_popup": "./popup/index.html", 23 | "default_title": "Open the popup", 24 | "default_icon": "icon-72x72.png" 25 | }, 26 | "permissions": [], 27 | "icons": { 28 | "72": "icon-72x72.png", 29 | "96": "icon-96x96.png", 30 | "128": "icon-128x128.png", 31 | "144": "icon-144x144.png", 32 | "152": "icon-152x152.png" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | important: '#extension-root', 4 | content: [ 5 | "./content-script/**/*.{js,ts,jsx,tsx}", 6 | "./popup/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | container: { 10 | padding: '2rem', 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": false, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "experimentalDecorators": true, 13 | "module": "ESNext", 14 | "moduleResolution": "Node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" 19 | }, 20 | "include": ["src"], 21 | "references": [{ "path": "./tsconfig.node.json" }] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "target": "ESNext", 6 | "moduleResolution": "nodenext", 7 | "allowSyntheticDefaultImports": true, 8 | "experimentalDecorators": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.chrome.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | 3 | import { defineConfig } from 'vite' 4 | 5 | const fetchVersion = () => { 6 | return { 7 | name: 'html-transform', 8 | transformIndexHtml(html: string) { 9 | return html.replace( 10 | /__APP_VERSION__/, 11 | `v${process.env.npm_package_version}` 12 | ) 13 | } 14 | } 15 | } 16 | 17 | // https://vitejs.dev/config/ 18 | export default defineConfig({ 19 | plugins: [fetchVersion()], 20 | build: { 21 | emptyOutDir: false, 22 | outDir: path.resolve(__dirname, 'dist'), 23 | lib: { 24 | formats: ['iife'], 25 | entry: path.resolve(__dirname, 'background', 'index.ts'), 26 | name: 'Cat Facts' 27 | }, 28 | rollupOptions: { 29 | output: { 30 | entryFileNames: 'background/background.js', 31 | extend: true, 32 | } 33 | } 34 | } 35 | }) 36 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | 3 | const fetchVersion = () => { 4 | return { 5 | name: 'html-transform', 6 | transformIndexHtml(html) { 7 | return html.replace( 8 | /__APP_VERSION__/, 9 | `v${process.env.npm_package_version}` 10 | ) 11 | } 12 | } 13 | } 14 | 15 | export default defineConfig({ 16 | plugins: [fetchVersion()], 17 | build: { 18 | outDir: 'dist', 19 | emptyOutDir: false, 20 | rollupOptions: { 21 | input: { 22 | popup: new URL('./popup/index.html', import.meta.url).pathname, 23 | background: new URL('./background/index.html', import.meta.url).pathname 24 | }, 25 | output: { 26 | entryFileNames: "[name]/[name].js" 27 | } 28 | } 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /vite.content.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | 3 | import { defineConfig } from 'vite' 4 | 5 | export default defineConfig({ 6 | plugins: [], 7 | define: { 8 | 'process.env': {} 9 | }, 10 | build: { 11 | emptyOutDir: false, 12 | outDir: path.resolve(__dirname, 'dist'), 13 | lib: { 14 | formats: ['iife'], 15 | entry: path.resolve(__dirname, 'content-script', 'index.tsx'), 16 | name: 'Cat Facts' 17 | }, 18 | rollupOptions: { 19 | output: { 20 | entryFileNames: 'contentScript/index.js', 21 | extend: true, 22 | } 23 | } 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /wdio.conf.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from '@wdio/types' 2 | 3 | export const config: Options.Testrunner = { 4 | // 5 | autoCompileOpts: { 6 | autoCompile: true, 7 | tsNodeOpts: { 8 | project: './tsconfig.node.json', 9 | transpileOnly: true 10 | } 11 | }, 12 | // 13 | // ================== 14 | // Specify Test Files 15 | // ================== 16 | // Define which test specs should run. The pattern is relative to the directory 17 | // of the configuration file being run. 18 | // 19 | // The specs are defined as an array of spec files (optionally using wildcards 20 | // that will be expanded). The test for each spec file will be run in a separate 21 | // worker process. In order to have a group of spec files run in the same worker 22 | // process simply enclose them in an array within the specs array. 23 | // 24 | // If you are calling `wdio` from an NPM script (see https://docs.npmjs.com/cli/run-script), 25 | // then the current working directory is where your `package.json` resides, so `wdio` 26 | // will be called from there. 27 | // 28 | specs: [], 29 | // Patterns to exclude. 30 | exclude: [], 31 | // 32 | // ============ 33 | // Capabilities 34 | // ============ 35 | // Define your capabilities here. WebdriverIO can run multiple capabilities at the same 36 | // time. Depending on the number of capabilities, WebdriverIO launches several test 37 | // sessions. Within your capabilities you can overwrite the spec and exclude options in 38 | // order to group specific specs to a specific capability. 39 | // 40 | // First, you can define how many instances should be started at the same time. Let's 41 | // say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have 42 | // set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec 43 | // files and you set maxInstances to 10, all spec files will get tested at the same time 44 | // and 30 processes will get spawned. The property handles how many capabilities 45 | // from the same test should run tests. 46 | // 47 | maxInstances: 10, 48 | // 49 | // If you have trouble getting all important capabilities together, check out the 50 | // Sauce Labs platform configurator - a great tool to configure your capabilities: 51 | // https://saucelabs.com/platform/platform-configurator 52 | // 53 | capabilities: [], 54 | // 55 | // =================== 56 | // Test Configurations 57 | // =================== 58 | // Define all options that are relevant for the WebdriverIO instance here 59 | // 60 | // Level of logging verbosity: trace | debug | info | warn | error | silent 61 | logLevel: 'info', 62 | // 63 | // Set specific log levels per logger 64 | // loggers: 65 | // - webdriver, webdriverio 66 | // - @wdio/browserstack-service, @wdio/devtools-service, @wdio/sauce-service 67 | // - @wdio/mocha-framework, @wdio/jasmine-framework 68 | // - @wdio/local-runner 69 | // - @wdio/sumologic-reporter 70 | // - @wdio/cli, @wdio/config, @wdio/utils 71 | // Level of logging verbosity: trace | debug | info | warn | error | silent 72 | // logLevels: { 73 | // webdriver: 'info', 74 | // '@wdio/appium-service': 'info' 75 | // }, 76 | // 77 | // If you only want to run your tests until a specific amount of tests have failed use 78 | // bail (default is 0 - don't bail, run all tests). 79 | bail: 0, 80 | // 81 | // Set a base URL in order to shorten url command calls. If your `url` parameter starts 82 | // with `/`, the base url gets prepended, not including the path portion of your baseUrl. 83 | // If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url 84 | // gets prepended directly. 85 | baseUrl: '', 86 | // 87 | // Default timeout for all waitFor* commands. 88 | waitforTimeout: 10000, 89 | // 90 | // Default timeout in milliseconds for request 91 | // if browser driver or grid doesn't send response 92 | connectionRetryTimeout: 120000, 93 | // 94 | // Default request retries count 95 | connectionRetryCount: 3, 96 | // 97 | // Test runner services 98 | // Services take over a specific job you don't want to take care of. They enhance 99 | // your test setup with almost no effort. Unlike plugins, they don't add new 100 | // commands. Instead, they hook themselves up into the test process. 101 | services: [], 102 | 103 | // Framework you want to run your specs with. 104 | // The following are supported: Mocha, Jasmine, and Cucumber 105 | // see also: https://webdriver.io/docs/frameworks 106 | // 107 | // Make sure you have the wdio adapter package for the specific framework installed 108 | // before running any tests. 109 | framework: 'mocha', 110 | // 111 | // The number of times to retry the entire specfile when it fails as a whole 112 | // specFileRetries: 1, 113 | // 114 | // Delay in seconds between the spec file retry attempts 115 | // specFileRetriesDelay: 0, 116 | // 117 | // Whether or not retried specfiles should be retried immediately or deferred to the end of the queue 118 | // specFileRetriesDeferred: false, 119 | // 120 | // Test reporter for stdout. 121 | // The only one supported by default is 'dot' 122 | // see also: https://webdriver.io/docs/dot-reporter 123 | reporters: ['spec'], 124 | // 125 | // Options to be passed to Mocha. 126 | // See the full list at http://mochajs.org/ 127 | mochaOpts: { 128 | ui: 'bdd', 129 | timeout: 60000 130 | }, 131 | // 132 | // ===== 133 | // Hooks 134 | // ===== 135 | // WebdriverIO provides several hooks you can use to interfere with the test process in order to enhance 136 | // it and to build services around it. You can either apply a single function or an array of 137 | // methods to it. If one of them returns with a promise, WebdriverIO will wait until that promise got 138 | // resolved to continue. 139 | /** 140 | * Gets executed once before all workers get launched. 141 | * @param {Object} config wdio configuration object 142 | * @param {Array.} capabilities list of capabilities details 143 | */ 144 | // onPrepare: function (config, capabilities) { 145 | // }, 146 | /** 147 | * Gets executed before a worker process is spawned and can be used to initialise specific service 148 | * for that worker as well as modify runtime environments in an async fashion. 149 | * @param {String} cid capability id (e.g 0-0) 150 | * @param {[type]} caps object containing capabilities for session that will be spawn in the worker 151 | * @param {[type]} specs specs to be run in the worker process 152 | * @param {[type]} args object that will be merged with the main configuration once worker is initialized 153 | * @param {[type]} execArgv list of string arguments passed to the worker process 154 | */ 155 | // onWorkerStart: function (cid, caps, specs, args, execArgv) { 156 | // }, 157 | /** 158 | * Gets executed just after a worker process has exited. 159 | * @param {String} cid capability id (e.g 0-0) 160 | * @param {Number} exitCode 0 - success, 1 - fail 161 | * @param {[type]} specs specs to be run in the worker process 162 | * @param {Number} retries number of retries used 163 | */ 164 | // onWorkerEnd: function (cid, exitCode, specs, retries) { 165 | // }, 166 | /** 167 | * Gets executed just before initialising the webdriver session and test framework. It allows you 168 | * to manipulate configurations depending on the capability or spec. 169 | * @param {Object} config wdio configuration object 170 | * @param {Array.} capabilities list of capabilities details 171 | * @param {Array.} specs List of spec file paths that are to be run 172 | * @param {String} cid worker id (e.g. 0-0) 173 | */ 174 | // beforeSession: function (config, capabilities, specs, cid) { 175 | // }, 176 | /** 177 | * Gets executed before test execution begins. At this point you can access to all global 178 | * variables like `browser`. It is the perfect place to define custom commands. 179 | * @param {Array.} capabilities list of capabilities details 180 | * @param {Array.} specs List of spec file paths that are to be run 181 | * @param {Object} browser instance of created browser/device session 182 | */ 183 | // before: function (capabilities, specs) { 184 | // }, 185 | /** 186 | * Runs before a WebdriverIO command gets executed. 187 | * @param {String} commandName hook command name 188 | * @param {Array} args arguments that command would receive 189 | */ 190 | // beforeCommand: function (commandName, args) { 191 | // }, 192 | /** 193 | * Hook that gets executed before the suite starts 194 | * @param {Object} suite suite details 195 | */ 196 | // beforeSuite: function (suite) { 197 | // }, 198 | /** 199 | * Function to be executed before a test (in Mocha/Jasmine) starts. 200 | */ 201 | // beforeTest: function (test, context) { 202 | // }, 203 | /** 204 | * Hook that gets executed _before_ a hook within the suite starts (e.g. runs before calling 205 | * beforeEach in Mocha) 206 | */ 207 | // beforeHook: function (test, context) { 208 | // }, 209 | /** 210 | * Hook that gets executed _after_ a hook within the suite starts (e.g. runs after calling 211 | * afterEach in Mocha) 212 | */ 213 | // afterHook: function (test, context, { error, result, duration, passed, retries }) { 214 | // }, 215 | /** 216 | * Function to be executed after a test (in Mocha/Jasmine only) 217 | * @param {Object} test test object 218 | * @param {Object} context scope object the test was executed with 219 | * @param {Error} result.error error object in case the test fails, otherwise `undefined` 220 | * @param {Any} result.result return object of test function 221 | * @param {Number} result.duration duration of test 222 | * @param {Boolean} result.passed true if test has passed, otherwise false 223 | * @param {Object} result.retries informations to spec related retries, e.g. `{ attempts: 0, limit: 0 }` 224 | */ 225 | // afterTest: function(test, context, { error, result, duration, passed, retries }) { 226 | // }, 227 | 228 | 229 | /** 230 | * Hook that gets executed after the suite has ended 231 | * @param {Object} suite suite details 232 | */ 233 | // afterSuite: function (suite) { 234 | // }, 235 | /** 236 | * Runs after a WebdriverIO command gets executed 237 | * @param {String} commandName hook command name 238 | * @param {Array} args arguments that command would receive 239 | * @param {Number} result 0 - command success, 1 - command error 240 | * @param {Object} error error object if any 241 | */ 242 | // afterCommand: function (commandName, args, result, error) { 243 | // }, 244 | /** 245 | * Gets executed after all tests are done. You still have access to all global variables from 246 | * the test. 247 | * @param {Number} result 0 - test pass, 1 - test fail 248 | * @param {Array.} capabilities list of capabilities details 249 | * @param {Array.} specs List of spec file paths that ran 250 | */ 251 | // after: function (result, capabilities, specs) { 252 | // }, 253 | /** 254 | * Gets executed right after terminating the webdriver session. 255 | * @param {Object} config wdio configuration object 256 | * @param {Array.} capabilities list of capabilities details 257 | * @param {Array.} specs List of spec file paths that ran 258 | */ 259 | // afterSession: function (config, capabilities, specs) { 260 | // }, 261 | /** 262 | * Gets executed after all workers got shut down and the process is about to exit. An error 263 | * thrown in the onComplete hook will result in the test run failing. 264 | * @param {Object} exitCode 0 - success, 1 - fail 265 | * @param {Object} config wdio configuration object 266 | * @param {Array.} capabilities list of capabilities details 267 | * @param {} results object containing test results 268 | */ 269 | // onComplete: function(exitCode, config, capabilities, results) { 270 | // }, 271 | /** 272 | * Gets executed when a refresh happens. 273 | * @param {String} oldSessionId session ID of the old session 274 | * @param {String} newSessionId session ID of the new session 275 | */ 276 | // onReload: function(oldSessionId, newSessionId) { 277 | // } 278 | } 279 | -------------------------------------------------------------------------------- /wdio.e2e.conf.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import url from 'node:url' 3 | import fs from 'node:fs/promises' 4 | 5 | import { browser } from '@wdio/globals' 6 | import type { Options, Capabilities } from '@wdio/types' 7 | 8 | import pkg from './package.json' assert { type: 'json' } 9 | import { config as baseConfig } from './wdio.conf.js' 10 | 11 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) 12 | const chromeExtension = (await fs.readFile(path.join(__dirname, `web-extension-chrome-v${pkg.version}.crx`))).toString('base64') 13 | const firefoxExtensionPath = path.resolve(__dirname, `web-extension-firefox-v${pkg.version}.xpi`) 14 | 15 | async function openExtensionPopup (this: WebdriverIO.Browser, extensionName: string, popupUrl = 'index.html') { 16 | if ((this.capabilities as Capabilities.Capabilities).browserName !== 'chrome') { 17 | throw new Error('This command only works with Chrome') 18 | } 19 | await this.url('chrome://extensions/') 20 | 21 | const extensions = await this.$$('>>> extensions-item') 22 | const extension = await extensions.find(async (ext) => (await ext.$('#name').getText()) === extensionName) 23 | 24 | if (!extension) { 25 | const installedExtensions = await extensions.map((ext) => ext.$('#name').getText()) 26 | throw new Error(`Couldn't find extension "${extensionName}", available installed extensions are "${installedExtensions.join('", "')}"`) 27 | } 28 | 29 | const extId = await extension.getAttribute('id') 30 | await this.url(`chrome-extension://${extId}/popup/${popupUrl}`) 31 | } 32 | 33 | declare global { 34 | namespace WebdriverIO { 35 | interface Browser { 36 | openExtensionPopup: typeof openExtensionPopup 37 | } 38 | } 39 | } 40 | 41 | export const config: Options.Testrunner = { 42 | ...baseConfig, 43 | specs: ['./e2e/**/*.e2e.ts'], 44 | capabilities: [{ 45 | browserName: 'chrome', 46 | 'goog:chromeOptions': { 47 | args: ['--headless=new'], 48 | extensions: [chromeExtension] 49 | } 50 | }, { 51 | browserName: 'firefox', 52 | 'moz:firefoxOptions': { 53 | args: ['-headless'] 54 | } 55 | }], 56 | before: async (capabilities) => { 57 | browser.addCommand('openExtensionPopup', openExtensionPopup) 58 | const browserName = (capabilities as Capabilities.Capabilities).browserName 59 | 60 | if (browserName === 'firefox') { 61 | const extension = await fs.readFile(firefoxExtensionPath) 62 | await browser.installAddOn(extension.toString('base64'), true) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /wdio.ui.conf.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from '@wdio/types' 2 | 3 | import { config as baseConfig } from './wdio.conf.js' 4 | 5 | export const config: Options.Testrunner = { 6 | ...baseConfig, 7 | specs: [[ 8 | './content-script/*.test.tsx', 9 | './popup/*.test.tsx' 10 | ]], 11 | runner: ['browser', { 12 | preset: 'react', 13 | headless: !process.env.DEBUG 14 | }], 15 | capabilities: [{ 16 | browserName: 'chrome' 17 | }, { 18 | browserName: 'firefox' 19 | }] 20 | } 21 | --------------------------------------------------------------------------------