├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .taskcluster.yml ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── eslint.config.mjs ├── extension ├── background.js ├── content │ ├── broom.svg │ ├── download.svg │ ├── index.html │ ├── list-2.3.1.min.js │ ├── script.js │ └── style.css ├── experiments │ └── remotesettings │ │ ├── api.js │ │ └── schema.json ├── icon.svg └── manifest.json ├── package-lock.json ├── package.json ├── renovate.json ├── screenshot.png ├── tests └── selenium.spec.js └── update.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | - main 6 | pull_request: 7 | 8 | name: CI 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version-file: 'package.json' 18 | cache: 'npm' 19 | 20 | - name: Print environment 21 | run: | 22 | node --version 23 | npm --version 24 | 25 | - name: Install Node dependencies 26 | run: npm ci 27 | 28 | - name: Code Style 29 | run: npm run cs-check 30 | 31 | - name: Code Lint 32 | run: npm run lint 33 | 34 | - name: Ext Lint 35 | run: npx web-ext lint --ignore-files="**/*.min.js" --warnings-as-errors --privileged --self-hosted --source-dir=extension/ 36 | 37 | test: 38 | runs-on: ubuntu-latest 39 | env: 40 | TEST_TAG: user/app:test 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - name: Build container 45 | uses: docker/build-push-action@v6 46 | with: 47 | tags: ${{ env.TEST_TAG }} 48 | file: Dockerfile 49 | load: true 50 | context: . 51 | 52 | - name: Run container 53 | run: | 54 | docker run --rm ${{ env.TEST_TAG }} && sleep 5 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | web-ext-artifacts/ 3 | node_modules/ 4 | .vscode/ 5 | rs-devtools/ 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | extension/content/list-2.3.1.min.js 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | trailingComma: "all", 4 | proseWrap: "always", 5 | }; 6 | -------------------------------------------------------------------------------- /.taskcluster.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | reporting: checks-v1 4 | policy: 5 | pullRequests: public 6 | tasks: 7 | - $let: 8 | # XXX Set to `true` for private repos 9 | privateRepo: false 10 | # XXX Use 11 | # `system` for system add-on, 12 | # `privileged` for AMO or self-hosted privileged add-on, 13 | # `mozillaonline-privileged` for Mozilla China add-on, 14 | # `normandy-privileged` for normandy add-on 15 | # to enable siging on push/PR. 16 | xpiSigningType: "privileged" 17 | 18 | template: 19 | repo: https://github.com/mozilla-extensions/xpi-template 20 | branch: main 21 | trustDomain: xpi 22 | in: 23 | $if: 'tasks_for in ["github-pull-request", "github-push", "action", "cron"]' 24 | then: 25 | $let: 26 | # Github events have this stuff in different places... 27 | ownerEmail: 28 | $if: 'tasks_for == "github-push"' 29 | then: '${event.pusher.email}' 30 | # Assume Pull Request 31 | else: 32 | $if: 'tasks_for == "github-pull-request"' 33 | then: '${event.pull_request.user.login}@users.noreply.github.com' 34 | else: 35 | $if: 'tasks_for in ["cron", "action"]' 36 | then: '${tasks_for}@noreply.mozilla.org' 37 | project: 38 | $if: 'tasks_for == "github-push"' 39 | then: '${event.repository.name}' 40 | else: 41 | $if: 'tasks_for == "github-pull-request"' 42 | then: '${event.pull_request.head.repo.name}' 43 | else: 44 | $if: 'tasks_for in ["cron", "action"]' 45 | then: '${repository.project}' 46 | head_branch: 47 | $if: 'tasks_for == "github-pull-request"' 48 | then: ${event.pull_request.head.ref} 49 | else: 50 | $if: 'tasks_for == "github-push"' 51 | then: ${event.ref} 52 | else: 53 | $if: 'tasks_for in ["cron", "action"]' 54 | then: '${push.branch}' 55 | head_sha: 56 | $if: 'tasks_for == "github-push"' 57 | then: '${event.after}' 58 | else: 59 | $if: 'tasks_for == "github-pull-request"' 60 | then: '${event.pull_request.head.sha}' 61 | else: 62 | $if: 'tasks_for in ["cron", "action"]' 63 | then: '${push.revision}' 64 | ownTaskId: 65 | $if: '"github" in tasks_for' 66 | then: {$eval: as_slugid("decision_task")} 67 | else: 68 | $if: 'tasks_for in ["cron", "action"]' 69 | then: '${ownTaskId}' 70 | repoFullName: 71 | $if: 'tasks_for in "github-push"' 72 | then: '${event.repository.full_name}' 73 | else: 74 | $if: 'tasks_for == "github-pull-request"' 75 | then: '${event.pull_request.base.repo.full_name}' 76 | else: 77 | $if: 'tasks_for in ["cron", "action"]' 78 | # Trim https://github.com/ 79 | then: '${repository.url[19:]}' 80 | baseRepoUrl: 81 | $if: '!privateRepo' # public repo 82 | then: 83 | $if: 'tasks_for == "github-push"' 84 | then: '${event.repository.html_url}' 85 | else: 86 | $if: 'tasks_for == "github-pull-request"' 87 | then: '${event.pull_request.base.repo.html_url}' 88 | else: 89 | $if: 'tasks_for in ["cron", "action"]' 90 | then: '${repository.url}' 91 | else: 92 | $if: 'tasks_for == "github-push"' 93 | then: '${event.repository.ssh_url}' 94 | else: 95 | $if: 'tasks_for == "github-pull-request"' 96 | then: '${event.pull_request.base.repo.ssh_url}' 97 | else: 98 | $if: 'tasks_for in ["cron", "action"]' 99 | then: '${repository.url}' 100 | repoUrl: 101 | $if: '!privateRepo' # public repo 102 | then: 103 | $if: 'tasks_for == "github-push"' 104 | then: '${event.repository.html_url}' 105 | else: 106 | $if: 'tasks_for == "github-pull-request"' 107 | then: '${event.pull_request.head.repo.html_url}' 108 | else: 109 | $if: 'tasks_for in ["cron", "action"]' 110 | then: '${repository.url}' 111 | else: 112 | $if: 'tasks_for == "github-push"' 113 | then: '${event.repository.ssh_url}' 114 | else: 115 | $if: 'tasks_for == "github-pull-request"' 116 | then: '${event.pull_request.base.repo.ssh_url}' 117 | else: 118 | $if: 'tasks_for in ["cron", "action"]' 119 | then: '${repository.url}' 120 | in: 121 | $let: 122 | level: 1 123 | in: 124 | taskId: 125 | $if: 'tasks_for != "action"' 126 | then: '${ownTaskId}' 127 | taskGroupId: 128 | $if: 'tasks_for == "action"' 129 | then: 130 | '${action.taskGroupId}' 131 | else: 132 | '${ownTaskId}' # same as taskId; this is how automation identifies a decision task 133 | schedulerId: '${trustDomain}-level-${level}' 134 | created: {$fromNow: ''} 135 | deadline: {$fromNow: '1 day'} 136 | expires: {$fromNow: '1 year 1 second'} # 1 second so artifacts expire first, despite rounding errors 137 | metadata: 138 | $merge: 139 | - owner: "${ownerEmail}" 140 | - $if: '!privateRepo' # public repo 141 | then: 142 | source: '${repoUrl}/raw/${head_sha}/.taskcluster.yml' 143 | else: 144 | source: 'ssh://github.com/${repoUrl[15:-4]}/raw/${head_sha}/.taskcluster.yml' 145 | - $if: 'tasks_for in ["github-push", "github-pull-request"]' 146 | then: 147 | name: "Decision Task" 148 | description: 'The task that creates all of the other tasks in the task graph' 149 | else: 150 | $if: 'tasks_for == "action"' 151 | then: 152 | name: "Action: ${action.title}" 153 | description: '${action.description}' 154 | else: 155 | name: "Decision Task for cron job ${cron.job_name}" 156 | description: 'Created by a [cron task](https://tools.taskcluster.net/tasks/${cron.task_id})' 157 | provisionerId: "xpi-${level}" 158 | workerType: "decision-gcp" 159 | tags: 160 | $if: 'tasks_for in ["github-push", "github-pull-request"]' 161 | then: 162 | kind: decision-task 163 | else: 164 | $if: 'tasks_for == "action"' 165 | then: 166 | kind: 'action-callback' 167 | else: 168 | $if: 'tasks_for == "cron"' 169 | then: 170 | kind: cron-task 171 | routes: 172 | $flatten: 173 | - checks 174 | - $if: 'tasks_for == "github-push"' 175 | then: 176 | - "index.${trustDomain}.v2.${project}.revision.${head_sha}.taskgraph.decision" 177 | else: [] 178 | scopes: 179 | $if: 'tasks_for == "github-push"' 180 | then: 181 | $let: 182 | short_head_branch: 183 | $if: 'head_branch[:10] == "refs/tags/"' 184 | then: {$eval: 'head_branch[10:]'} 185 | else: 186 | $if: 'head_branch[:11] == "refs/heads/"' 187 | then: {$eval: 'head_branch[11:]'} 188 | else: ${head_branch} 189 | in: 190 | - 'assume:repo:github.com/${repoFullName}:branch:${short_head_branch}' 191 | 192 | 193 | else: 194 | $if: 'tasks_for == "github-pull-request"' 195 | then: 196 | - 'assume:repo:github.com/${repoFullName}:pull-request' 197 | 198 | else: 199 | $if: 'tasks_for == "action"' 200 | then: 201 | # when all actions are hooks, we can calculate this directly rather than using a variable 202 | - '${action.repo_scope}' 203 | else: 204 | - 'assume:repo:github.com/${repoFullName}:cron:${cron.job_name}' 205 | 206 | 207 | requires: all-completed 208 | priority: lowest 209 | retries: 5 210 | 211 | payload: 212 | env: 213 | # run-task uses these to check out the source; the inputs 214 | # to `mach taskgraph decision` are all on the command line. 215 | $merge: 216 | - XPI_BASE_REPOSITORY: '${baseRepoUrl}' 217 | XPI_HEAD_REPOSITORY: '${repoUrl}' 218 | XPI_HEAD_REF: '${head_branch}' 219 | XPI_HEAD_REV: '${head_sha}' 220 | XPI_REPOSITORY_TYPE: git 221 | XPI_SIGNING_TYPE: '${xpiSigningType}' 222 | TEMPLATE_BASE_REPOSITORY: '${template.repo}' 223 | TEMPLATE_HEAD_REPOSITORY: '${template.repo}' 224 | TEMPLATE_HEAD_REV: '${template.branch}' 225 | TEMPLATE_HEAD_REF: '${template.branch}' 226 | TEMPLATE_REPOSITORY_TYPE: git 227 | TEMPLATE_PIP_REQUIREMENTS: taskcluster/requirements.txt 228 | REPOSITORIES: {$json: {xpi: "XPI Manifest", template: "XPI Template"}} 229 | HG_STORE_PATH: /builds/worker/checkouts/hg-store 230 | - $if: 'privateRepo' 231 | then: 232 | XPI_SSH_SECRET_NAME: project/xpi/xpi-github-clone-ssh 233 | - $if: 'tasks_for in ["github-pull-request"]' 234 | then: 235 | XPI_PULL_REQUEST_NUMBER: '${event.pull_request.number}' 236 | - $if: 'tasks_for == "action"' 237 | then: 238 | ACTION_TASK_GROUP_ID: '${action.taskGroupId}' # taskGroupId of the target task 239 | ACTION_TASK_ID: {$json: {$eval: 'taskId'}} # taskId of the target task (JSON-encoded) 240 | ACTION_INPUT: {$json: {$eval: 'input'}} 241 | ACTION_CALLBACK: '${action.cb_name}' 242 | features: 243 | taskclusterProxy: true 244 | chainOfTrust: true 245 | # Note: This task is built server side without the context or tooling that 246 | # exist in tree so we must hard code the hash 247 | image: mozillareleases/taskgraph:decision-5483484ad45a3d27a0f5bd05f1c87d90e08df67a3713605d812b851a8a5bd854@sha256:ef132cc5741539f846a85bbe0cebc3c9ead30b8f24c1da46c55363f2170c3993 248 | 249 | maxRunTime: 1800 250 | 251 | command: 252 | - /usr/local/bin/run-task 253 | - '--xpi-checkout=/builds/worker/checkouts/src' 254 | - '--template-checkout=/builds/worker/checkouts/template' 255 | - '--task-cwd=/builds/worker/checkouts/src' 256 | - '--' 257 | - bash 258 | - -cx 259 | - $let: 260 | extraArgs: {$if: 'tasks_for == "cron"', then: '${cron.quoted_args}', else: ''} 261 | in: 262 | $if: 'tasks_for == "action"' 263 | then: > 264 | cd /builds/worker/checkouts/src && 265 | rm -rf taskcluster && 266 | ln -s /builds/worker/checkouts/template/taskcluster taskcluster && 267 | ln -s /builds/worker/artifacts artifacts && 268 | ~/.local/bin/taskgraph action-callback 269 | else: > 270 | rm -rf taskcluster && 271 | ln -s /builds/worker/checkouts/template/taskcluster taskcluster && 272 | ln -s /builds/worker/artifacts artifacts && 273 | ~/.local/bin/taskgraph decision 274 | --pushlog-id='0' 275 | --pushdate='0' 276 | --project='${project}' 277 | --message="" 278 | --owner='${ownerEmail}' 279 | --level='${level}' 280 | --base-repository="$XPI_BASE_REPOSITORY" 281 | --head-repository="$XPI_HEAD_REPOSITORY" 282 | --head-ref="$XPI_HEAD_REF" 283 | --head-rev="$XPI_HEAD_REV" 284 | --repository-type="$XPI_REPOSITORY_TYPE" 285 | --tasks-for='${tasks_for}' 286 | ${extraArgs} 287 | 288 | artifacts: 289 | 'public': 290 | type: 'directory' 291 | path: '/builds/worker/artifacts' 292 | expires: {$fromNow: '1 year'} 293 | 294 | extra: 295 | $merge: 296 | - $if: 'tasks_for == "action"' 297 | then: 298 | parent: '${action.taskGroupId}' 299 | action: 300 | name: '${action.name}' 301 | context: 302 | taskGroupId: '${action.taskGroupId}' 303 | taskId: {$eval: 'taskId'} 304 | input: {$eval: 'input'} 305 | - $if: 'tasks_for == "cron"' 306 | then: 307 | cron: {$json: {$eval: 'cron'}} 308 | - tasks_for: '${tasks_for}' 309 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 9 | 10 | 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Only used for testing 2 | FROM node:22-bookworm 3 | 4 | WORKDIR /opt 5 | 6 | # pull latest nightly 7 | RUN wget -O nightly.tar.bz2 "https://download.mozilla.org/?product=firefox-nightly-latest-ssl&os=linux64&lang=en-US" 8 | RUN tar -xf nightly.tar.bz2 9 | 10 | # install firefox dependencies 11 | RUN apt update && apt install -y libasound2 libgtk-3-0 libx11-xcb1 12 | 13 | # copy files over 14 | COPY . ./ 15 | 16 | # install node dependencies 17 | RUN npm ci 18 | 19 | ENV NIGHTLY_PATH="/opt/firefox/firefox" 20 | 21 | CMD npm run tcs:test 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remote Settings Devtools 2 | 3 | This addon provides some tools to assist developers with remote settings. 4 | 5 | # Features 6 | 7 | - Trigger synchronization manually 8 | - Inspect local data 9 | - Clear local data 10 | - Switch from/to DEV, STAGE, or PROD 11 | 12 | ![](screenshot.png) 13 | 14 | 15 | # Install 16 | 17 | ## Firefox for desktop 18 | 19 | - Pick the .xpi file from the [releases page](https://github.com/mozilla-extensions/remote-settings-devtools/releases). 20 | - When asked for confirmation, select "Continue to installation". 21 | 22 | > Note: it is highly recommended to use a temporary or development user profile 23 | 24 | ## Firefox for Android 25 | 26 | - Download the .xpi file from the [releases page](https://github.com/mozilla-extensions/remote-settings-devtools/releases). 27 | - Enable the Debug Menu: https://firefox-source-docs.mozilla.org/mobile/android/fenix/Secret-settings-debug-menu-instructions.html 28 | - Install the extension from file 29 | 30 | # Development 31 | 32 | 33 | This addon relies on the [Experiments API](https://firefox-source-docs.mozilla.org/toolkit/components/extensions/webextensions/basics.html#webextensions-experiments) in order to expose Remote Settings internals to the Web Extension. 34 | 35 | Unsigned addons with experiments can only be loaded in Firefox Nightly and Developer Edition, with specific preferences set. 36 | 37 | 1. Download [Nightly](https://www.mozilla.org/en-US/firefox/channel/desktop/#nightly) 38 | 2. Install dependencies with `npm install` 39 | 3. Run `npm run start:macos` or `npm run start:linux`, and it will spawn a browser window with the addon installed 40 | 4. Enjoy! 41 | 42 | It relies on [web-ext](https://github.com/mozilla/web-ext). Additional CLI params can be passed using `--`: 43 | 44 | ``` 45 | npx run start:linux -- --firefox-profile rs-devtools --profile-create-if-missing --keep-profile-changes 46 | ``` 47 | 48 | More information about the temporary loaded addon can be found in `about:debugging#/runtime/this-firefox` 49 | 50 | ## Running tests locally 51 | 52 | Automated tests can be run locally or in docker to verify changes. 53 | - `npm run tcs:docker` - will build and run the automated tests within a container using the latest firefox nightly package. 54 | - `npm run tcs:test` to run automated tests locally using your firefox nightly package. 55 | - Setting the `NIGHTLY_PATH` environment parameter will allow you to run tests against an arbitrary firefox nightly binary. Ex: `NIGHTLY_PATH=/opt/custom/nightly npm run tcs:test` 56 | 57 | # Release 58 | 59 | ### Prerequisites (get access to Ship-It) 60 | 61 | 1. Create a ticket to be added to the VPN group (can clone and edit [this Bugzilla ticket](https://bugzilla.mozilla.org/show_bug.cgi?id=1740098)) 62 | 2. Ask in the [#addons-pipeline](https://mozilla.slack.com/archives/CMKP7NPKN) channel to be added to the `XPI_PRIVILEGED_BUILD_GROUP` to get access to create an XPI release for `remote-settings-devtools` on [Ship-It](https://shipit.mozilla-releng.net/) 63 | 64 | ### Create a new tag/release 65 | 66 | 1. Bump version in `package.json` and `extension/manifest.json` 67 | 2. Tag commit `git tag -a X.Y.Z` and push it `git push origin X.Y.Z` 68 | 3. Create release with changelog on [GitHub's releases page](https://github.com/mozilla-extensions/remote-settings-devtools/releases/new) 69 | 4. Check that `FirefoxCI` action has run for tagged commit 70 | 71 | ### Create release on Ship-It 72 | 73 | 1. Ensure you're connected to the VPN 74 | 2. Go to [Ship-It](https://shipit.mozilla-releng.net/) 75 | 3. Login with SSO at the top right 76 | 4. Click `Releases` > `Extensions` > `New` at the top and select the following options: 77 | - `Available XPIs` → `remote-settings-devtools` 78 | - `Available revisions` → revision with the commit hash associated with the tag that's being released 79 | 5. Ensure the version that was tagged is the one shown 80 | 6. Select `CREATE RELEASE` → `SUBMIT` 81 | 7. Scroll to the bottom of the [pending releases page](https://shipit.mozilla-releng.net/xpi) 82 | 8. Click `Build` > `Schedule` on the new release labeled `remote-settings-devtools-X.Y.Z-build1` 83 | 9. Submit a [sign off request](https://mana.mozilla.org/wiki/pages/viewpage.action?spaceKey=FDPDT&title=Mozilla+Add-on+Review+Requests+Intake) 84 | 85 | The binary will be published in the [releases page](https://github.com/mozilla-extensions/remote-settings-devtools/releases) automatically. 86 | 87 | 10. Update the `update.json` file to reflect the `version` value assigned by Taskcluster (eg. `1.9.0buildid20240422.103808`) and the download URL for the XPI file. 88 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import json from "eslint-plugin-json"; 3 | import mozilla from "eslint-plugin-mozilla"; 4 | import pluginJest from "eslint-plugin-jest"; 5 | 6 | export default [ 7 | { 8 | ignores: [ 9 | "node_modules/", 10 | "web-ext-artifacts/", 11 | "extension/content/*.min.js", 12 | ], 13 | }, 14 | { 15 | languageOptions: { 16 | globals: { 17 | ...globals.es2024, 18 | }, 19 | }, 20 | }, 21 | ...mozilla.configs["flat/recommended"], 22 | { 23 | files: ["**/*.json"], 24 | plugins: { json }, 25 | processor: json.processors[".json"], 26 | rules: json.configs.recommended.rules, 27 | }, 28 | { 29 | files: ["extension/**"], 30 | languageOptions: { 31 | globals: { 32 | ...globals.webextensions, 33 | }, 34 | }, 35 | }, 36 | { 37 | files: ["extension/content/*.js"], 38 | languageOptions: { 39 | globals: { 40 | ...globals.browser, 41 | }, 42 | }, 43 | }, 44 | { 45 | files: ["extension/experiments/**/*.js"], 46 | languageOptions: { 47 | sourceType: "script", 48 | globals: { 49 | ...mozilla.environments.privileged.globals, 50 | }, 51 | }, 52 | }, 53 | { 54 | files: [ 55 | "tests/*.spec.js", 56 | ], 57 | languageOptions: { 58 | sourceType: "module", 59 | globals: { 60 | ...globals.node, 61 | ...pluginJest.environments.globals.globals, 62 | }, 63 | }, 64 | }, 65 | { 66 | files: [ 67 | ".prettierrc.js", 68 | ], 69 | languageOptions: { 70 | sourceType: "script", 71 | globals: { 72 | ...globals.node, 73 | }, 74 | }, 75 | }, 76 | ]; -------------------------------------------------------------------------------- /extension/background.js: -------------------------------------------------------------------------------- 1 | browser.browserAction.onClicked.addListener(async () => { 2 | await browser.tabs.create({ 3 | url: "content/index.html", 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /extension/content/broom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /extension/content/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /extension/content/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 |
CollectionLast CheckServer TimestampLocal TimestampActions
71 |
72 | 73 | 74 | 95 | 96 | 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /extension/content/list-2.3.1.min.js: -------------------------------------------------------------------------------- 1 | var List;List=function(){var t={"./src/add-async.js":function(t){t.exports=function(t){return function e(r,n,s){var i=r.splice(0,50);s=(s=s||[]).concat(t.add(i)),r.length>0?setTimeout((function(){e(r,n,s)}),1):(t.update(),n(s))}}},"./src/filter.js":function(t){t.exports=function(t){return t.handlers.filterStart=t.handlers.filterStart||[],t.handlers.filterComplete=t.handlers.filterComplete||[],function(e){if(t.trigger("filterStart"),t.i=1,t.reset.filter(),void 0===e)t.filtered=!1;else{t.filtered=!0;for(var r=t.items,n=0,s=r.length;nv.page,a=new g(t[s],void 0,n),v.items.push(a),r.push(a)}return v.update(),r}m(t.slice(0),e)}},this.show=function(t,e){return this.i=t,this.page=e,v.update(),v},this.remove=function(t,e,r){for(var n=0,s=0,i=v.items.length;s-1&&r.splice(n,1),v},this.trigger=function(t){for(var e=v.handlers[t].length;e--;)v.handlers[t][e](v);return v},this.reset={filter:function(){for(var t=v.items,e=t.length;e--;)t[e].filtered=!1;return v},search:function(){for(var t=v.items,e=t.length;e--;)t[e].found=!1;return v}},this.update=function(){var t=v.items,e=t.length;v.visibleItems=[],v.matchingItems=[],v.templater.clear();for(var r=0;r=v.i&&v.visibleItems.lengthe},innerWindow:function(t,e,r){return t>=e-r&&t<=e+r},dotted:function(t,e,r,n,s,i,a){return this.dottedLeft(t,e,r,n,s,i)||this.dottedRight(t,e,r,n,s,i,a)},dottedLeft:function(t,e,r,n,s,i){return e==r+1&&!this.innerWindow(e,s,i)&&!this.right(e,n)},dottedRight:function(t,e,r,n,s,i,a){return!t.items[a-1].values().dotted&&(e==n&&!this.innerWindow(e,s,i)&&!this.right(e,n))}};return function(e){var n=new i(t.listContainer.id,{listClass:e.paginationClass||"pagination",item:e.item||"
  • ",valueNames:["page","dotted"],searchClass:"pagination-search-that-is-not-supposed-to-exist",sortClass:"pagination-sort-that-is-not-supposed-to-exist"});s.bind(n.listContainer,"click",(function(e){var r=e.target||e.srcElement,n=t.utils.getAttribute(r,"data-page"),s=t.utils.getAttribute(r,"data-i");s&&t.show((s-1)*n+1,n)})),t.on("updated",(function(){r(n,e)})),r(n,e)}}},"./src/parse.js":function(t,e,r){t.exports=function(t){var e=r("./src/item.js")(t),n=function(r,n){for(var s=0,i=r.length;s0?setTimeout((function(){e(r,s)}),1):(t.update(),t.trigger("parseComplete"))};return t.handlers.parseComplete=t.handlers.parseComplete||[],function(){var e=function(t){for(var e=t.childNodes,r=[],n=0,s=e.length;n]/g.exec(t)){var e=document.createElement("tbody");return e.innerHTML=t,e.firstElementChild}if(-1!==t.indexOf("<")){var r=document.createElement("div");return r.innerHTML=t,r.firstElementChild}}},a=function(e,r,n){var s=void 0,i=function(e){for(var r=0,n=t.valueNames.length;r=1;)t.list.removeChild(t.list.firstChild)},function(){var r;if("function"!=typeof t.item){if(!(r="string"==typeof t.item?-1===t.item.indexOf("<")?document.getElementById(t.item):i(t.item):s()))throw new Error("The list needs to have at least one item on init otherwise you'll have to add a template.");r=n(r,t.valueNames),e=function(){return r.cloneNode(!0)}}else e=function(e){var r=t.item(e);return i(r)}}()};t.exports=function(t){return new e(t)}},"./src/utils/classes.js":function(t,e,r){var n=r("./src/utils/index-of.js"),s=/\s+/;Object.prototype.toString;function i(t){if(!t||!t.nodeType)throw new Error("A DOM element reference is required");this.el=t,this.list=t.classList}t.exports=function(t){return new i(t)},i.prototype.add=function(t){if(this.list)return this.list.add(t),this;var e=this.array();return~n(e,t)||e.push(t),this.el.className=e.join(" "),this},i.prototype.remove=function(t){if(this.list)return this.list.remove(t),this;var e=this.array(),r=n(e,t);return~r&&e.splice(r,1),this.el.className=e.join(" "),this},i.prototype.toggle=function(t,e){return this.list?(void 0!==e?e!==this.list.toggle(t,e)&&this.list.toggle(t):this.list.toggle(t),this):(void 0!==e?e?this.add(t):this.remove(t):this.has(t)?this.remove(t):this.add(t),this)},i.prototype.array=function(){var t=(this.el.getAttribute("class")||"").replace(/^\s+|\s+$/g,"").split(s);return""===t[0]&&t.shift(),t},i.prototype.has=i.prototype.contains=function(t){return this.list?this.list.contains(t):!!~n(this.array(),t)}},"./src/utils/events.js":function(t,e,r){var n=window.addEventListener?"addEventListener":"attachEvent",s=window.removeEventListener?"removeEventListener":"detachEvent",i="addEventListener"!==n?"on":"",a=r("./src/utils/to-array.js");e.bind=function(t,e,r,s){for(var o=0,l=(t=a(t)).length;o32)return!1;var a=n,o=function(){var t,r={};for(t=0;t=p;b--){var j=o[t.charAt(b-1)];if(C[b]=0===m?(C[b+1]<<1|1)&j:(C[b+1]<<1|1)&j|(v[b+1]|v[b])<<1|1|v[b+1],C[b]&d){var x=l(m,b-1);if(x<=u){if(u=x,!((c=b-1)>a))break;p=Math.max(1,2*a-c)}}}if(l(m+1,a)>u)break;v=C}return!(c<0)}},"./src/utils/get-attribute.js":function(t){t.exports=function(t,e){var r=t.getAttribute&&t.getAttribute(e)||null;if(!r)for(var n=t.attributes,s=n.length,i=0;i=48&&t<=57}function i(t,e){for(var i=(t+="").length,a=(e+="").length,o=0,l=0;o=i&&l=a?-1:l>=a&&o=i?1:i-a}i.caseInsensitive=i.i=function(t,e){return i((""+t).toLowerCase(),(""+e).toLowerCase())},Object.defineProperties(i,{alphabet:{get:function(){return e},set:function(t){r=[];var s=0;if(e=t)for(;s (el.className = el.className.replace(" loading", ""))); 30 | } 31 | } 32 | 33 | /** 34 | * Shows an error message for the whole sync. 35 | * @param {Error} error 36 | */ 37 | function showGlobalError(error) { 38 | showLoading(false); 39 | if (error) { 40 | console.error("Global error", error); 41 | } 42 | document.getElementById("polling-error").textContent = error; 43 | } 44 | 45 | /** 46 | * Shows an error message for the whole sync. 47 | * @param {Error} error 48 | */ 49 | function showSyncError(bucket, collection, error) { 50 | showLoading(false); 51 | if (error) { 52 | console.error(`Sync error for ${bucket}/${collection}`, error); 53 | } 54 | const tableRowId = `status-${bucket}/${collection}`; 55 | const row = document.getElementById(tableRowId); 56 | row.querySelector(".error").textContent = error; 57 | } 58 | /** 59 | * Refreshes the whole UI. 60 | */ 61 | async function refreshUI(state) { 62 | const { 63 | serverURL, 64 | serverTimestamp, 65 | localTimestamp, 66 | lastCheck, 67 | collections, 68 | pollingEndpoint, 69 | environment, 70 | history, 71 | serverSettingIgnored, 72 | signaturesEnabled, 73 | } = state; 74 | 75 | showLoading(false); 76 | 77 | const environmentElt = document.getElementById("environment"); 78 | environmentElt.value = environment; 79 | document.getElementById("environment-error").style.display = 80 | serverSettingIgnored ? "block" : "none"; 81 | if (serverSettingIgnored) { 82 | // Disable all options except those related to prod 83 | environmentElt 84 | .querySelectorAll("option:not(.prod)") 85 | .forEach((optionElt) => optionElt.setAttribute("disabled", "disabled")); 86 | } 87 | 88 | document.getElementById("polling-url").textContent = new URL( 89 | pollingEndpoint, 90 | ).origin; 91 | document.getElementById("polling-url").setAttribute("href", pollingEndpoint); 92 | document.getElementById("local-timestamp").textContent = localTimestamp; 93 | document.getElementById("server-timestamp").textContent = serverTimestamp; 94 | document.getElementById("human-local-timestamp").className = 95 | localTimestamp == serverTimestamp ? " up-to-date" : " unsync"; 96 | document.getElementById("human-local-timestamp").textContent = 97 | humanDate(localTimestamp); 98 | document.getElementById("human-server-timestamp").textContent = 99 | humanDate(serverTimestamp); 100 | document.getElementById("last-check").textContent = humanDate( 101 | lastCheck * 1000, 102 | ); 103 | 104 | // Sync history. 105 | const historyTpl = document.getElementById("sync-history-entry-tpl"); 106 | const historyList = document.querySelector("#sync-history > ul"); 107 | historyList.innerHTML = ""; 108 | history["settings-sync"].forEach((entry) => { 109 | const entryRow = historyTpl.content.cloneNode(true); 110 | entryRow.querySelector(".datetime").textContent = humanDate( 111 | entry.timestamp, 112 | ); 113 | entryRow.querySelector(".status").textContent = entry.status; 114 | entryRow.querySelector(".status").className += ` ${entry.status}`; 115 | historyList.appendChild(entryRow); 116 | }); 117 | 118 | // Options 119 | document.getElementById("enable-signatures").checked = signaturesEnabled; 120 | 121 | // Table of collections. 122 | const tpl = document.getElementById("collection-status-tpl"); 123 | const statusTable = document.querySelector("#status table tbody"); 124 | 125 | statusTable.innerHTML = ""; 126 | collections.forEach((status) => { 127 | const { 128 | bucket, 129 | collection, 130 | lastCheck: lastCheckCollection, 131 | localTimestamp: localTimestampCollection, 132 | serverTimestamp: serverTimestampCollection, 133 | } = status; 134 | const url = `${serverURL}/buckets/${bucket}/collections/${collection}/changeset?_expected=${serverTimestamp}`; 135 | const identifier = `${bucket}/${collection}`; 136 | 137 | const tableRowId = `status-${identifier}`; 138 | const tableRow = tpl.content.cloneNode(true); 139 | tableRow.querySelector("tr").setAttribute("id", tableRowId); 140 | tableRow.querySelector(".url").textContent = identifier; 141 | tableRow.querySelector(".url").setAttribute("href", url); 142 | tableRow.querySelector(".human-server-timestamp").textContent = humanDate( 143 | serverTimestampCollection, 144 | ); 145 | tableRow.querySelector(".server-timestamp").textContent = 146 | serverTimestampCollection; 147 | tableRow.querySelector(".human-local-timestamp").className += 148 | localTimestampCollection == serverTimestampCollection 149 | ? " up-to-date" 150 | : " unsync"; 151 | tableRow.querySelector(".human-local-timestamp").textContent = humanDate( 152 | localTimestampCollection, 153 | ); 154 | tableRow.querySelector(".local-timestamp").textContent = localTimestamp; 155 | tableRow.querySelector(".last-check").textContent = humanDate( 156 | lastCheckCollection * 1000, 157 | ); 158 | 159 | tableRow.querySelector("button.clear-data").onclick = async () => { 160 | document.getElementById(tableRowId).className += " loading"; 161 | await remotesettings.deleteLocal(collection); 162 | }; 163 | tableRow.querySelector("button.sync").onclick = async () => { 164 | document.getElementById(tableRowId).className += " loading"; 165 | await remotesettings.forceSync(collection); 166 | }; 167 | statusTable.appendChild(tableRow); 168 | }); 169 | const options = { 170 | valueNames: [ 171 | "collection", 172 | "last-check", 173 | "server-timestamp", 174 | "local-timestamp", 175 | ], 176 | }; 177 | // eslint-disable-next-line no-undef 178 | new List("status-table", options); 179 | } 180 | 181 | async function main() { 182 | // Load the UI in the background. 183 | remotesettings 184 | .getState() 185 | .then((data) => { 186 | showLoading(false); 187 | refreshUI(data); 188 | }) 189 | .catch(showGlobalError); 190 | 191 | remotesettings.onStateChanged.addListener((data) => { 192 | showLoading(false); 193 | try { 194 | refreshUI(JSON.parse(data)); 195 | } catch (e) { 196 | showGlobalError(e); 197 | } 198 | }); 199 | remotesettings.onGlobalError.addListener((error) => showGlobalError(error)); 200 | remotesettings.onSyncError.addListener((data) => { 201 | const { bucket, collection, error } = JSON.parse(data); 202 | showSyncError(bucket, collection, error); 203 | }); 204 | 205 | document.getElementById("environment").onchange = async (event) => { 206 | showGlobalError(null); 207 | showLoading(true); 208 | await remotesettings.switchEnvironment(event.target.value); 209 | }; 210 | 211 | document.getElementById("enable-signatures").onchange = async (event) => { 212 | await remotesettings.enableSignatureVerification(event.target.checked); 213 | }; 214 | 215 | // Poll for changes button. 216 | document.getElementById("run-poll").onclick = async () => { 217 | showGlobalError(null); 218 | showLoading(true); 219 | await remotesettings.pollChanges(); 220 | }; 221 | 222 | // Clear all data. 223 | document.getElementById("clear-all-data").onclick = async () => { 224 | showGlobalError(null); 225 | showLoading(true); 226 | await remotesettings.deleteAllLocal(); 227 | }; 228 | } 229 | 230 | window.addEventListener("DOMContentLoaded", main); 231 | -------------------------------------------------------------------------------- /extension/content/style.css: -------------------------------------------------------------------------------- 1 | @import url("chrome://global/skin/in-content/common.css"); 2 | 3 | html, body { 4 | max-height: 100vh; 5 | max-width: 100vw; 6 | 7 | --success-text-color: green; 8 | --error-text-color: red; 9 | --subtext-color: #888; 10 | } 11 | 12 | body { 13 | color: var(--in-content-text-color); 14 | } 15 | 16 | @media(min-width: 1000px) { 17 | body { 18 | margin: 40px; 19 | } 20 | } 21 | 22 | body.loading { 23 | opacity: 0.5; 24 | } 25 | 26 | h2, h3 { 27 | margin: unset; 28 | } 29 | 30 | dt { 31 | margin-top: .5em; 32 | font-weight: bold; 33 | } 34 | 35 | dl { 36 | margin: 0 2em 0 0; 37 | width: max-content; 38 | display: inline-block; 39 | } 40 | 41 | dd { 42 | margin-left: 1em; 43 | } 44 | 45 | .error { 46 | color: var(--error-text-color); 47 | } 48 | 49 | section { 50 | background-color: var(--in-content-box-background); 51 | padding: 10px; 52 | word-break: break-all; 53 | } 54 | 55 | section.bordered { 56 | border: 1px solid var(--in-content-border-color); 57 | } 58 | 59 | #header { 60 | display: flex; 61 | gap: 10px; 62 | margin-bottom: 10px; 63 | } 64 | 65 | #options { 66 | min-width: 8em; 67 | } 68 | 69 | #options button, #options select, #options span { 70 | display: block; 71 | width: 91%; 72 | margin: 4px 8px; 73 | } 74 | 75 | #header section { 76 | position: relative; 77 | vertical-align: top; 78 | border-radius: 4px; 79 | } 80 | 81 | #header section h2 { 82 | position: absolute; 83 | font-size: medium; 84 | top: -0.5em; 85 | background-color: var(--in-content-box-background); 86 | padding: 0 .3em; 87 | } 88 | 89 | #header-status { 90 | flex-grow: 1; 91 | } 92 | 93 | #actions { 94 | padding: 0px; 95 | padding-bottom: 10px; 96 | } 97 | 98 | #environment { 99 | text-align: center; 100 | } 101 | 102 | #environment-error { 103 | max-width: 300px; 104 | word-break: unset; 105 | display: none; 106 | } 107 | 108 | #environment-error pre { 109 | margin: 0px; 110 | } 111 | 112 | #status table { 113 | width: 100%; 114 | min-width: 860px; 115 | text-align: center; 116 | border-collapse: collapse; 117 | } 118 | #status table th { 119 | white-space: nowrap; 120 | min-width: 6em; 121 | } 122 | #status table td:first-child, #status table th:first-child { 123 | text-align: left 124 | } 125 | #status table tr:not(:first-child) { 126 | border-top: 1px solid #757575; 127 | } 128 | 129 | .collection-status.loading { 130 | opacity: 0.5; 131 | } 132 | .collection-status a.url { 133 | font-size: 1.4em; 134 | white-space: nowrap; 135 | } 136 | .collection-status span:last-child { 137 | display: block; 138 | } 139 | .collection-status button { 140 | display: inline-block; 141 | } 142 | 143 | .local-timestamp, .server-timestamp, #local-timestamp, #server-timestamp { 144 | color: var(--subtext-color); 145 | } 146 | 147 | .up-to-date::before { 148 | content: "✔"; 149 | color: var(--success-text-color); 150 | } 151 | 152 | .unsync::before { 153 | content: "⚠"; 154 | color: var(--error-text-color); 155 | } 156 | 157 | button.action { 158 | content: ' '; 159 | width: 28px; 160 | height: 28px; 161 | min-width: 28px; 162 | background-size: 28px; 163 | margin-right: 5px; 164 | cursor: pointer; 165 | background-repeat: no-repeat; 166 | } 167 | 168 | @media (prefers-color-scheme: dark) { 169 | button.action { 170 | filter: invert(1); 171 | } 172 | } 173 | 174 | button.sync { 175 | background-image: url('download.svg'); 176 | } 177 | 178 | button.clear-data { 179 | background-image: url('broom.svg'); 180 | } 181 | 182 | #sync-history { 183 | min-width: 19em; 184 | } 185 | 186 | #sync-history > ul { 187 | max-height: 14em; 188 | overflow-y: scroll; 189 | margin: 0; 190 | padding-left: 10px; 191 | } 192 | @media(min-width: 1255px) { 193 | /* When #header-status switches to 2 columns */ 194 | #sync-history > ul { 195 | max-height: 8em; 196 | } 197 | } 198 | 199 | #sync-history li { 200 | margin: 10px 0px 10px 10px; 201 | } 202 | 203 | #sync-history .status.success { 204 | background-color: var(--success-text-color); 205 | } 206 | 207 | #sync-history .status:not(.success) { 208 | background-color: var(--error-text-color); 209 | } 210 | 211 | .sort { 212 | cursor: pointer; 213 | } 214 | 215 | .sort:after { 216 | content: "▼▲"; 217 | padding-left: 10px; 218 | opacity: 0.5; 219 | } 220 | .sort.desc:after { 221 | content: "▼"; 222 | opacity: 1; 223 | } 224 | .sort.asc:after { 225 | content: "▲"; 226 | opacity: 1; 227 | } 228 | -------------------------------------------------------------------------------- /extension/experiments/remotesettings/api.js: -------------------------------------------------------------------------------- 1 | ChromeUtils.defineESModuleGetters(this, { 2 | RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", 3 | }); 4 | 5 | /* global ExtensionAPI, ExtensionCommon, ExtensionUtils, Services */ 6 | 7 | const { EventManager } = ExtensionCommon; 8 | const { ExtensionError } = ExtensionUtils; 9 | 10 | const SERVER_LOCAL = "http://localhost:8888/v1"; 11 | const SERVER_PROD = "https://firefox.settings.services.mozilla.com/v1"; 12 | const SERVER_STAGE = "https://firefox.settings.services.allizom.org/v1"; 13 | const SERVER_DEV = "https://remote-settings-dev.allizom.org/v1"; 14 | const MEGAPHONE_STAGE = "wss://autoconnect.stage.mozaws.net"; 15 | 16 | async function getState() { 17 | const inspected = await RemoteSettings.inspect(); 18 | 19 | const { collections, serverURL, previewMode } = inspected; 20 | let environment = "custom"; 21 | switch (serverURL) { 22 | case SERVER_PROD: 23 | environment = "prod"; 24 | break; 25 | case SERVER_STAGE: 26 | environment = "stage"; 27 | break; 28 | case SERVER_DEV: 29 | environment = "dev"; 30 | break; 31 | case SERVER_LOCAL: 32 | environment = "local"; 33 | break; 34 | } 35 | 36 | if (previewMode) { 37 | environment += "-preview"; 38 | } 39 | 40 | // Detect whether user tried to switch server, and whether it had effect or not. 41 | let serverSettingIgnored = false; 42 | if (Services.prefs.prefHasUserValue("services.settings.server")) { 43 | const manuallySet = Services.prefs.getStringPref( 44 | "services.settings.server", 45 | ); 46 | if (manuallySet != serverURL) { 47 | serverSettingIgnored = true; 48 | } 49 | } 50 | // Same for preview mode. 51 | if (Services.prefs.prefHasUserValue("services.settings.preview_enabled")) { 52 | const manuallyEnabled = Services.prefs.getBoolPref( 53 | "services.settings.preview_enabled", 54 | ); 55 | if (manuallyEnabled && !previewMode) { 56 | serverSettingIgnored = true; 57 | } 58 | } 59 | 60 | // If one collection has signature verification enabled, then consider 61 | // it's enabled for all in the UI. 62 | const signaturesEnabled = collections.some(({ collection, bucket }) => { 63 | const c = RemoteSettings(collection, { bucketName: bucket }); 64 | return c.verifySignature; 65 | }); 66 | 67 | return { 68 | ...inspected, 69 | environment, 70 | serverSettingIgnored, 71 | signaturesEnabled, 72 | }; 73 | } 74 | 75 | function refreshUI() { 76 | Services.obs.notifyObservers(null, "remotesettings-state-changed"); 77 | } 78 | 79 | function reportError(error) { 80 | // If the error is for a particular collection then some details are attached 81 | // (see RemoteSettings::pollChanges) 82 | if (error.details) { 83 | const { bucket, collection } = error.details; 84 | console.error(`Error with ${bucket}/${collection}`, error); 85 | Services.obs.notifyObservers( 86 | null, 87 | "remotesettings-sync-error", 88 | JSON.stringify({ 89 | bucket, 90 | collection, 91 | error: error.toString(), 92 | }), 93 | ); 94 | } else { 95 | console.error(error); 96 | // eg. polling error, network error etc. 97 | Services.obs.notifyObservers( 98 | null, 99 | "remotesettings-global-error", 100 | error.toString(), 101 | ); 102 | } 103 | } 104 | 105 | var remotesettings = class extends ExtensionAPI { 106 | getAPI(context) { 107 | return { 108 | experiments: { 109 | remotesettings: { 110 | getState, 111 | 112 | async pollChanges() { 113 | // Generate a fake timestamp to bust cache. 114 | const randomCacheBust = 99990000 + Math.floor(Math.random() * 9999); 115 | try { 116 | await RemoteSettings.pollChanges({ 117 | expectedTimestamp: randomCacheBust, 118 | }); 119 | refreshUI(); 120 | } catch (e) { 121 | reportError(e); 122 | } 123 | }, 124 | 125 | /** 126 | * setEnvironment() will set the necessary internal preferences to switch from 127 | * an environment to another. 128 | */ 129 | async switchEnvironment(env) { 130 | if (env.includes("prod")) { 131 | Services.prefs.setCharPref( 132 | "services.settings.server", 133 | SERVER_PROD, 134 | ); 135 | Services.prefs.clearUserPref("dom.push.serverURL"); 136 | } else if (env.includes("stage")) { 137 | Services.prefs.setCharPref( 138 | "services.settings.server", 139 | SERVER_STAGE, 140 | ); 141 | Services.prefs.setCharPref("dom.push.serverURL", MEGAPHONE_STAGE); 142 | } else if (env.includes("dev")) { 143 | Services.prefs.setCharPref( 144 | "services.settings.server", 145 | SERVER_DEV, 146 | ); 147 | Services.prefs.clearUserPref("dom.push.serverURL"); 148 | } else if (env.includes("local")) { 149 | Services.prefs.setCharPref( 150 | "services.settings.server", 151 | SERVER_LOCAL, 152 | ); 153 | Services.prefs.clearUserPref("dom.push.serverURL"); 154 | } 155 | 156 | const previewMode = env.includes("-preview"); 157 | RemoteSettings.enablePreviewMode(previewMode); 158 | // Set pref to persist change across restarts. 159 | Services.prefs.setBoolPref( 160 | "services.settings.preview_enabled", 161 | previewMode, 162 | ); 163 | 164 | refreshUI(); 165 | }, 166 | 167 | /** 168 | * enableSignatureVerification() enables or disables signature 169 | * verification on all known collections. 170 | * @param {bool} enabled true to enable, false to disable 171 | */ 172 | async enableSignatureVerification(enabled) { 173 | const { collections } = await RemoteSettings.inspect(); 174 | for (const { collection, bucket } of collections) { 175 | RemoteSettings(collection, { 176 | bucketName: bucket, 177 | }).verifySignature = enabled; 178 | } 179 | }, 180 | 181 | /** 182 | * deleteLocal() deletes the local records of the specified collection. 183 | * @param {String} collection collection name 184 | */ 185 | async deleteLocal(collection) { 186 | try { 187 | const client = RemoteSettings(collection); 188 | Services.prefs.clearUserPref(client.lastCheckTimePref); 189 | 190 | await client.db.clear(); 191 | await client.attachments.prune([]); 192 | 193 | refreshUI(); 194 | } catch (e) { 195 | reportError(e); 196 | } 197 | }, 198 | 199 | /** 200 | * forceSync() will trigger a synchronization at the level only for the specified collection. 201 | * @param {String} collection collection name 202 | */ 203 | async forceSync(collection) { 204 | try { 205 | const client = RemoteSettings(collection); 206 | await client.sync(); 207 | 208 | refreshUI(); 209 | } catch (e) { 210 | reportError(e); 211 | } 212 | }, 213 | 214 | /** 215 | * deleteAllLocal() deletes the local records of every known collection. 216 | */ 217 | async deleteAllLocal() { 218 | try { 219 | const { collections } = await RemoteSettings.inspect(); 220 | // Delete each collection sequentially to avoid collisions in IndexedBD. 221 | for (const { collection } of collections) { 222 | await this.deleteLocal(collection); 223 | } 224 | 225 | refreshUI(); 226 | } catch (e) { 227 | reportError(e); 228 | } 229 | }, 230 | 231 | onStateChanged: new EventManager({ 232 | context, 233 | name: "remotesettings.onStateChanged", 234 | register: (fire) => { 235 | const observer = async () => { 236 | const state = await getState(); 237 | fire.async(JSON.stringify(state)); 238 | }; 239 | Services.obs.addObserver( 240 | observer, 241 | "remotesettings-state-changed", 242 | ); 243 | Services.obs.addObserver( 244 | observer, 245 | "remote-settings-changes-polled", 246 | ); 247 | return () => { 248 | Services.obs.removeObserver( 249 | observer, 250 | "remotesettings-state-changed", 251 | ); 252 | Services.obs.removeObserver( 253 | observer, 254 | "remote-settings-changes-polled", 255 | ); 256 | }; 257 | }, 258 | }).api(), 259 | 260 | onGlobalError: new EventManager({ 261 | context, 262 | name: "remotesettings.onGlobalError", 263 | register: (fire) => { 264 | const observer = (subject, topic, data) => { 265 | fire.async(data); 266 | }; 267 | Services.obs.addObserver(observer, "remotesettings-global-error"); 268 | return () => 269 | Services.obs.removeObserver( 270 | observer, 271 | "remotesettings-global-error", 272 | ); 273 | }, 274 | }).api(), 275 | 276 | onSyncError: new EventManager({ 277 | context, 278 | name: "remotesettings.onSyncError", 279 | register: (fire) => { 280 | const observer = (subject, topic, data) => { 281 | fire.async(data); 282 | }; 283 | Services.obs.addObserver(observer, "remotesettings-sync-error"); 284 | return () => 285 | Services.obs.removeObserver( 286 | observer, 287 | "remotesettings-sync-error", 288 | ); 289 | }, 290 | }).api(), 291 | }, 292 | }, 293 | }; 294 | } 295 | }; 296 | -------------------------------------------------------------------------------- /extension/experiments/remotesettings/schema.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "namespace": "experiments.remotesettings", 4 | "description": "Remote Settings", 5 | "functions": [ 6 | { 7 | "name": "getState", 8 | "type": "function", 9 | "description": "Return current state", 10 | "async": true, 11 | "parameters": [] 12 | }, 13 | { 14 | "name": "pollChanges", 15 | "type": "function", 16 | "description": "Polls for changes", 17 | "async": true, 18 | "parameters": [] 19 | }, 20 | { 21 | "name": "switchEnvironment", 22 | "type": "function", 23 | "description": "Sets the necessary internal preferences to switch between DEV, STAGE, or PROD", 24 | "async": true, 25 | "parameters": [ 26 | { 27 | "name": "env", 28 | "type": "string", 29 | "description": "One of 'dev', 'stage', or 'prod'" 30 | } 31 | ] 32 | }, 33 | { 34 | "name": "enableSignatureVerification", 35 | "type": "function", 36 | "description": "Enable signatures verification", 37 | "async": true, 38 | "parameters": [ 39 | { 40 | "name": "enabled", 41 | "type": "boolean", 42 | "description": "true to enable, false to disable" 43 | } 44 | ] 45 | }, 46 | { 47 | "name": "deleteLocal", 48 | "type": "function", 49 | "description": "Deletes the local records of the specified collection", 50 | "async": true, 51 | "parameters": [ 52 | { 53 | "name": "collection", 54 | "type": "string", 55 | "description": "collection name" 56 | } 57 | ] 58 | }, 59 | { 60 | "name": "forceSync", 61 | "type": "function", 62 | "description": "Triggers a synchronization at the level only for the specified collection", 63 | "async": true, 64 | "parameters": [ 65 | { 66 | "name": "collection", 67 | "type": "string", 68 | "description": "collection name" 69 | } 70 | ] 71 | }, 72 | { 73 | "name": "deleteAllLocal", 74 | "type": "function", 75 | "description": "Deletes the local records of every known collection.", 76 | "async": true, 77 | "parameters": [] 78 | } 79 | ], 80 | "events": [ 81 | { 82 | "name": "onStateChanged", 83 | "type": "function", 84 | "description": "Internal state has changed", 85 | "parameters": [ 86 | { 87 | "name": "state", 88 | "description": "The new state", 89 | "type": "string" 90 | } 91 | ] 92 | }, 93 | { 94 | "name": "onGlobalError", 95 | "type": "function", 96 | "description": "General error", 97 | "parameters": [ 98 | { 99 | "name": "error", 100 | "description": "The error message", 101 | "type": "string" 102 | } 103 | ] 104 | }, 105 | { 106 | "name": "onSyncError", 107 | "type": "function", 108 | "description": "Collection error", 109 | "parameters": [ 110 | { 111 | "name": "bucket", 112 | "description": "The bucket name", 113 | "type": "string" 114 | }, 115 | { 116 | "name": "collection", 117 | "description": "The collection name", 118 | "type": "string" 119 | }, 120 | { 121 | "name": "error", 122 | "description": "The error message", 123 | "type": "string" 124 | } 125 | ] 126 | } 127 | ] 128 | } 129 | ] 130 | -------------------------------------------------------------------------------- /extension/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "remote-settings-devtools", 4 | "version": "1.11.0", 5 | "description": "A set of tools for interacting with the Firefox Remote Settings", 6 | "homepage_url": "https://github.com/mozilla-extensions/remote-settings-devtools", 7 | 8 | "permissions": [ 9 | "mozillaAddons" 10 | ], 11 | 12 | "browser_specific_settings": { 13 | "gecko": { 14 | "id": "remote-settings-devtools@mozilla.com", 15 | "strict_min_version": "112.0", 16 | "update_url": "https://raw.githubusercontent.com/mozilla-extensions/remote-settings-devtools/master/update.json" 17 | } 18 | }, 19 | 20 | "background": { 21 | "scripts": ["background.js"] 22 | }, 23 | 24 | "icons": { 25 | "48": "icon.svg", 26 | "96": "icon.svg" 27 | }, 28 | 29 | "browser_action": { 30 | "default_title": "Remote Settings Devtools", 31 | "browser_style": true 32 | }, 33 | 34 | "experiment_apis": { 35 | "remotesettings": { 36 | "schema": "experiments/remotesettings/schema.json", 37 | "parent": { 38 | "scopes": ["addon_parent"], 39 | "script": "experiments/remotesettings/api.js", 40 | "paths": [["experiments", "remotesettings"]] 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remote-settings-devtools", 3 | "version": "1.11.0", 4 | "description": "A set of tools for interacting with the Firefox Remote Settings", 5 | "homepage_url": "https://github.com/mozilla-extensions/remote-settings-devtools", 6 | "private": true, 7 | "license": "MPLv2", 8 | "docker-image": "node-lts-latest", 9 | "scripts": { 10 | "build": "npm-run-all clean build:*", 11 | "build:extension": "npx web-ext build -s ./extension/ --overwrite-dest", 12 | "build:finalize": "mv web-ext-artifacts/*.zip web-ext-artifacts/remote-settings-devtools.xpi", 13 | "clean": "npx rimraf web-ext-artifacts", 14 | "cs-check": "prettier -l \"{extension,tests}/**/*.{js,jsx,ts,tsx}\"", 15 | "cs-format": "prettier \"{extension,tests}/**/*.{js,jsx,ts,tsx}\" --write", 16 | "lint": "npm run lint:eslint", 17 | "lint:eslint": "eslint .", 18 | "lint:fix": "npm run lint:eslint -- --fix", 19 | "start:linux": "web-ext run --verbose --source-dir ./extension/ --firefox-binary /usr/bin/firefox-nightly --pref 'extensions.experiments.enabled=true'", 20 | "start:macos": "web-ext run --verbose --source-dir ./extension/ --firefox-binary '/Applications/Firefox Nightly.app/Contents/MacOS/firefox' --pref 'extensions.experiments.enabled=true'", 21 | "tcs:test": "npm-run-all clean build:* && jest --testTimeout=30000", 22 | "tcs:docker": "docker build . -t addon-test:latest && docker run --rm -it addon-test:latest" 23 | }, 24 | "devDependencies": { 25 | "@microsoft/eslint-plugin-sdl": "1.1.0", 26 | "eslint": "9.28.0", 27 | "eslint-config-prettier": "10.1.5", 28 | "eslint-plugin-fetch-options": "0.0.5", 29 | "eslint-plugin-html": "8.1.3", 30 | "eslint-plugin-json": "4.0.1", 31 | "eslint-plugin-jest": "28.11.0", 32 | "eslint-plugin-mozilla": "4.2.1", 33 | "eslint-plugin-no-unsanitized": "4.1.2", 34 | "prettier": "3.5.3", 35 | "jest": "^29.7.0", 36 | "npm-run-all2": "8.0.2", 37 | "rimraf": "6.0.1", 38 | "selenium-webdriver": "^4.20.0", 39 | "web-ext": "8.7.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla-extensions/remote-settings-devtools/9964b41176ba7ece13d752beea6d7771898e17c6/screenshot.png -------------------------------------------------------------------------------- /tests/selenium.spec.js: -------------------------------------------------------------------------------- 1 | const { Browser, Builder, By } = require("selenium-webdriver"); 2 | const { Options } = require("selenium-webdriver/firefox"); 3 | const FirefoxProfile = require("firefox-profile"); 4 | const path = require("path"); 5 | 6 | // create a static extension ID so we can find it's config page easily 7 | const testExtId = "2d7fbdec-9526-402c-badb-2fca5b65dfa8"; 8 | 9 | const busyWait = 200; // debounce 10 | let driver = null; 11 | 12 | beforeAll(async () => { 13 | // create a firefox profile that has our extension added to it 14 | const xpiPath = path.resolve( 15 | "./web-ext-artifacts/remote-settings-devtools.xpi", 16 | ); 17 | let profile = new FirefoxProfile(); 18 | profile.addExtension(xpiPath, (_err, _details) => {}); // empty function is required to load 19 | 20 | // setup firefox options that will allow our extension to run 21 | const options = new Options(profile.path()); 22 | options.setBinary(process.env.NIGHTLY_PATH || "/usr/bin/firefox-nightly"); 23 | options.addArguments("--pref 'extensions.experiments.enabled=true'"); 24 | options.addArguments("--headless"); 25 | options.setPreference("xpinstall.signatures.required", false); 26 | options.setPreference("extensions.experiments.enabled", true); 27 | options.setPreference( 28 | "extensions.webextensions.uuids", 29 | JSON.stringify({ 30 | "remote-settings-devtools@mozilla.com": testExtId, 31 | }), 32 | ); 33 | 34 | driver = await new Builder() 35 | .forBrowser(Browser.FIREFOX) 36 | .setFirefoxOptions(options) 37 | .build(); 38 | 39 | // install the addon 40 | await driver.installAddon(xpiPath); 41 | await driver.get(`moz-extension://${testExtId}/content/index.html`); 42 | 43 | // add mutation observer to listen for loading events 44 | // whenever an event flips from loading to unloading, update a hidden element to debounce 45 | await driver.executeScript(` 46 | const lastLoad = document.createElement('input'); 47 | lastLoad.id = "hdnLastLoad"; 48 | lastLoad.setAttribute('value', 0); 49 | 50 | const observer = new MutationObserver((mutations) => { 51 | for (let m of mutations) { 52 | if (m.attributeName === "class" && m.oldValue?.includes("loading")) { 53 | lastLoad.setAttribute('value', new Date().getTime()); 54 | } 55 | } 56 | }); 57 | 58 | observer.observe(document.querySelector('body'), { 59 | subtree: true, 60 | childList: true, 61 | attributeOldValue: true, 62 | attributeFilter: ["class"], 63 | }); 64 | 65 | document.querySelector('body').append(lastLoad); 66 | `); 67 | }); 68 | 69 | afterAll(async () => { 70 | driver.close(); 71 | }); 72 | 73 | // helper function to wait while data is being fetched 74 | async function waitForLoad() { 75 | let hasLoadingElements = false, 76 | debounceValue = 0; 77 | do { 78 | await driver.sleep(busyWait); 79 | hasLoadingElements = !!(await driver.findElements(By.css(".loading"))) 80 | .length; 81 | debounceValue = Number( 82 | await driver.findElement(By.id("hdnLastLoad")).getAttribute("value"), 83 | ); 84 | } while ( 85 | hasLoadingElements || 86 | debounceValue + busyWait > new Date().getTime() 87 | ); 88 | await driver.sleep(busyWait); 89 | } 90 | 91 | // making this a little easier to read in tests 92 | async function clickByCss(css, retries = 3) { 93 | let attempts = 0; 94 | while (attempts < retries) { 95 | try { 96 | let element = await driver.findElement(By.css(css)); 97 | await element.click(); 98 | return; 99 | } catch (error) { 100 | if (error.name === "StaleElementReferenceError") { 101 | console.warn(`Attempt ${attempts + 1} failed. Retrying...`); 102 | attempts++; 103 | await driver.sleep(busyWait); 104 | } else { 105 | // Re-throw other errors 106 | throw error; 107 | } 108 | } 109 | } 110 | } 111 | 112 | describe("End to end browser tests", () => { 113 | test("Load extension, change environment to prod, sync and clear all", async () => { 114 | // select prod environment from dropdown 115 | await clickByCss("#environment"); 116 | await clickByCss('#environment [value="prod"]'); 117 | await waitForLoad(); 118 | 119 | // verify table loads as expected and we have unsync'd data 120 | expect( 121 | (await driver.findElements(By.css("#status tr"))).length, 122 | ).toBeGreaterThan(1); 123 | expect( 124 | (await driver.findElements(By.css("#status .unsync"))).length, 125 | ).toBeGreaterThan(1); 126 | 127 | // pull latest data 128 | await clickByCss("#run-poll"); 129 | await waitForLoad(); 130 | 131 | // verify data as sync'd as expected 132 | expect( 133 | (await driver.findElements(By.css("#status .unsync"))).length, 134 | ).toBeLessThan( 135 | 4, // allowing for a few collections to fail due to networking issues in automated test 136 | ); 137 | expect( 138 | (await driver.findElements(By.css("#status .up-to-date"))).length, 139 | ).toBeGreaterThan(1); 140 | 141 | // clear all data 142 | await clickByCss("#clear-all-data"); 143 | await waitForLoad(); 144 | 145 | // verify everything is cleared as expected 146 | expect( 147 | (await driver.findElements(By.css("#status .unsync"))).length, 148 | ).toBeGreaterThan(1); 149 | expect( 150 | (await driver.findElements(By.css("#status .up-to-date"))).length, 151 | ).toBe(0); 152 | }); 153 | 154 | test("Clear and re-download a collection", async () => { 155 | // force sync the first collection and verify it worked 156 | await clickByCss("#status .sync"); 157 | await waitForLoad(); 158 | let firstTimestamp = await driver.findElement( 159 | By.css("#status .human-local-timestamp"), 160 | ); 161 | expect(await firstTimestamp.getAttribute("class")).toContain("up-to-date"); 162 | 163 | // force sync the first collection and verify it worked 164 | await clickByCss("#status .clear-data"); 165 | await waitForLoad(); 166 | firstTimestamp = await driver.findElement( 167 | By.css("#status .human-local-timestamp"), 168 | ); 169 | expect(await firstTimestamp.getAttribute("class")).toContain("unsync"); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /update.json: -------------------------------------------------------------------------------- 1 | { 2 | "addons": { 3 | "remote-settings-devtools@mozilla.com": { 4 | "updates": [ 5 | { 6 | "version": "1.11.0_TBD", 7 | "update_link": "https://github.com/mozilla-extensions/remote-settings-devtools/releases/download/1.11.0-build1/remote-settings-devtools.xpi" 8 | } 9 | ] 10 | } 11 | } 12 | } 13 | --------------------------------------------------------------------------------