├── .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.