├── .eslintrc.js ├── .gitignore ├── AUTHORS.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── addon ├── .eslintrc.js ├── background.js ├── constants.js ├── content │ ├── debug │ │ ├── debug.html │ │ └── debug.js │ ├── options │ │ ├── options.css │ │ ├── options.html │ │ └── options.js │ ├── popup │ │ ├── popup.css │ │ ├── popup.html │ │ └── popup.js │ └── release-notes │ │ ├── 0.5 │ │ ├── panel.png │ │ └── working-hours.png │ │ ├── 0.6 │ │ ├── needinfos-in-options.png │ │ ├── needinfos-in-panel.png │ │ └── no-phabricator-session.png │ │ ├── release-notes.css │ │ └── release-notes.html ├── icons │ ├── CREDITS.md │ ├── gear.svg │ ├── myqonly-dark.svg │ ├── myqonly-light.svg │ └── refresh.svg └── manifest.json ├── karma.conf.js ├── package.json ├── tests ├── .eslintrc.js ├── background.test.js ├── content │ └── options.test.js ├── services │ └── phabricator │ │ ├── empty.html │ │ ├── one-ready.html │ │ └── phabricator.test.js └── test-utils.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "rules": { 3 | "indent": [ 4 | "error", 5 | 2 6 | ], 7 | "quotes": [ 8 | 2, 9 | "double" 10 | ], 11 | "linebreak-style": [ 12 | 2, 13 | "unix" 14 | ], 15 | "max-len": [ 16 | "error", 17 | 80 18 | ], 19 | "semi": [ 20 | 2, 21 | "always" 22 | ], 23 | "no-console": "off", 24 | "comma-dangle": [ 25 | "error", 26 | "always" 27 | ], 28 | }, 29 | "env": { 30 | "es6": true, 31 | "browser": true 32 | }, 33 | "parserOptions": { 34 | "ecmaVersion": 2017 35 | }, 36 | "extends": "eslint:recommended" 37 | }; 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | web-ext-artifacts/ 3 | node_modules/ 4 | yarn_error.log 5 | *.swp 6 | *.sublime-* 7 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | Maintainer / Original author: [Mike Conley](https://github.com/mikeconley/) 2 | 3 | Contributors: 4 | * [thomcc](https://github.com/thomcc) 5 | * [6a68](https://github.com/6a68) 6 | * [Aaron Klotz](https://github.com/dblohm7/) 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Building the WebExtension 2 | 3 | We use [yarn](https://yarnpkg.com/) as package manager, so make sure you have that installed and available in your path. 4 | 5 | Then install the development dependencies by running `yarn install`. 6 | 7 | You can install the development version of MyQOnly by visiting `about:debugging` within Firefox, clicking "Load Temporary Add-on", and then browsing to the `manifest.json` file inside of the `addon/` directory. 8 | 9 | You can build an (unsigned) XPI of MyQOnly by running `yarn build`. 10 | 11 | # Linting 12 | 13 | [ESLint](https://eslint.org/) is used to keep our JavaScript consistent and tidy. You can run ESLint on the whole project (which will be installed when installing the development dependencies) by using `yarn lint`. 14 | 15 | You can lint an individual file or directory by using `yarn lint-file `. 16 | 17 | # Testing 18 | 19 | [sinon-chrome](https://www.npmjs.com/package/sinon-chrome) is used to mock out the WebExtension APIs, and some (very) basic unit tests exist within the test directory. You can run those tests with `yarn test`. 20 | 21 | You may need to set the `FIREFOX_BIN` environment variable, pointed at a local Firefox instance in order for the Karma launcher to launch it. 22 | 23 | Manual testing can be done by running `yarn test:manual`, which will open up a fresh instance of Firefox with MyQOnly installed. 24 | 25 | # Debugging 26 | 27 | MyQOnly has a very simple debugging interface built into it. Visit the Options page, and then hover your mouse down to the bottom right of the screen to expose a link to it. Right now, it allows you to trigger a manual refresh of the review count, as well as gather a current snapshot of the Phabricator dashboard for the user, which will hopefully be useful for testing and bug reproduction. 28 | 29 | # Issues and Pull Requests 30 | 31 | If you're going to send a pull request, please ensure that an [issue](https://github.com/mikeconley/myqonly/issues) has been filed for the work you're doing. 32 | 33 | Please ask [mikeconley](https://github.com/mikeconley/) to review your pull requests. -------------------------------------------------------------------------------- /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 | # MyQOnly 2 | 3 | [Install from AMO here.](https://addons.mozilla.org/en-US/firefox/addon/myqonly/) 4 | 5 | Suggestions for a better name welcome! 6 | 7 | This WebExtension attempts to provide a reasonably accurate count of how many reviews a Mozillian might have in their queue. It knows how to talk to Phabricator, Bugzilla and GitHub. It displays a badge in the toolbar showing how many reviews are in the queue, and the popup takes you to the dashboards for those two review tools. 8 | 9 | # Usage 10 | 11 | ## Phabricator 12 | 13 | MyQOnly will use any pre-existing browser session to get at your Phabricator data, so just login to Phabricator, and stay logged in. 14 | 15 | ## Bugzilla 16 | 17 | You'll need to generate an API key. Visit [the API keys](https://bugzilla.mozilla.org/userprefs.cgi?tab=apikey) section of Bugzilla Preferences to generate a new key. 18 | 19 | Once you have that API key, go to about:addons and visit the Preferences for MyQOnly. Paste in the API key, and set the update interval to your liking. 20 | 21 | ## GitHub 22 | You need to enter your GitHub username into the about:addons preferences in MyQOnly. 23 | 24 | # YMMV 25 | 26 | This is still very much a WIP, so it might not work perfectly. Please file bugs! 27 | -------------------------------------------------------------------------------- /addon/.eslintrc.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | "extends": "../.eslintrc.js", 5 | 6 | "globals": { 7 | "PHABRICATOR_ROOT": true, 8 | "PHABRICATOR_DASHBOARD": true, 9 | "PHABRICATOR_REVIEW_HEADERS": true, 10 | "BUGZILLA_API": true, 11 | "GITHUB_API": true, 12 | "DEFAULT_UPDATE_INTERVAL": true, 13 | "ALARM_NAME": true, 14 | "FEATURE_ALERT_REV": true, 15 | "FEATURE_ALERT_BG_COLOR": true, 16 | "FEATURE_ALERT_STRING": true, 17 | "browser": true, 18 | "chrome": true, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /addon/background.js: -------------------------------------------------------------------------------- 1 | // If we're running in the sinon-chrome test framework, we need to alias 2 | // the chrome namespace to browser. 3 | if (typeof(browser) == "undefined") { 4 | var browser = chrome; 5 | } 6 | 7 | var MyQOnly = { 8 | /** 9 | * Main entry. After set-up, attempts to update the badge right 10 | * away. 11 | */ 12 | async init({ alertRev = FEATURE_ALERT_REV, } = {}) { 13 | // Add a listener so that if our options change, we react to it. 14 | browser.storage.onChanged.addListener(this.onStorageChanged.bind(this)); 15 | // Hook up our timer 16 | browser.alarms.onAlarm.addListener(this.onAlarm.bind(this)); 17 | // Add a listener for the popup if it asks for review totals. 18 | browser.runtime.onMessage.addListener(this.onMessage.bind(this)); 19 | 20 | console.debug("Looking for feature rev"); 21 | let { featureRev, } = await browser.storage.local.get("featureRev"); 22 | if (!featureRev) { 23 | console.debug("No feature rev - this is a first timer."); 24 | featureRev = alertRev; 25 | await browser.storage.local.set({ featureRev, }); 26 | } else { 27 | console.debug("Got feature rev ", featureRev); 28 | } 29 | 30 | this.featureRev = featureRev; 31 | 32 | let { updateInterval, } = await browser.storage.local.get("updateInterval"); 33 | if (!updateInterval) { 34 | updateInterval = DEFAULT_UPDATE_INTERVAL; 35 | await browser.storage.local.set({ 36 | updateInterval, 37 | }); 38 | } 39 | this.updateInterval = updateInterval; 40 | 41 | this.states = new Map(); 42 | 43 | let { services, } = await browser.storage.local.get("services"); 44 | 45 | this.services = services || []; 46 | await this._initServices(); 47 | await this.resetAlarm(); 48 | await this.updateBadge(); 49 | }, 50 | 51 | uninit() { 52 | delete this.states; 53 | delete this.services; 54 | delete this.updateInterval; 55 | delete this.featureRev; 56 | this._nextServiceID = 0; 57 | }, 58 | 59 | /** 60 | * The following functions for manipulating services are for adding 61 | * defaults at initialization. Most service manipulation should really 62 | * be done by the user in the Options interface. 63 | */ 64 | _nextServiceID: 0, 65 | async _initServices() { 66 | let maxServiceID = this._nextServiceID; 67 | for (let service of this.services) { 68 | this.states.set(service.id, { 69 | type: service.type, 70 | data: {}, 71 | }); 72 | maxServiceID = Math.max(service.id, maxServiceID); 73 | } 74 | this._nextServiceID = maxServiceID + 1; 75 | 76 | // Introduce a new default service configuration for Phabricator. 77 | let phabService = this._getService("phabricator"); 78 | if (!phabService) { 79 | await this._addService("phabricator", { 80 | container: 0, 81 | inclReviewerGroups: true, 82 | }); 83 | } else if (phabService.settings.inclReviewerGroups === undefined) { 84 | phabService.settings.inclReviewerGroups = true; 85 | await browser.storage.local.set({ services: this.services, }); 86 | } 87 | }, 88 | 89 | /** 90 | * Returns a service if it exists, null otherwise. 91 | */ 92 | _getService(serviceType) { 93 | for (let service of this.services) { 94 | if (service.type == serviceType) { 95 | return service; 96 | } 97 | } 98 | return null; 99 | }, 100 | 101 | /** 102 | * Puts a service of serviceType into the services list with 103 | * the provided settings, and saves the services to storage. 104 | */ 105 | async _addService(serviceType, settings) { 106 | let newService = { 107 | id: this._nextServiceID, 108 | type: serviceType, 109 | settings, 110 | }; 111 | 112 | this._nextServiceID++; 113 | 114 | this.services.push(newService); 115 | 116 | await browser.storage.local.set({ services: this.services, }); 117 | this._ensureStatesForServices(); 118 | }, 119 | 120 | _ensureStatesForServices() { 121 | for (let service of this.services) { 122 | if (!this.states.has(service.id)) { 123 | this.states.set(service.id, { 124 | type: service.type, 125 | data: {}, 126 | }); 127 | } 128 | } 129 | }, 130 | 131 | /** 132 | * Handles updates to the user options. 133 | */ 134 | async onStorageChanged(changes, area) { 135 | if (area == "local") { 136 | // The user updated the update interval, so let's cancel the old 137 | // alarm and set up a new one. 138 | if (changes.updateInterval) { 139 | this.updateInterval = changes.updateInterval.newValue; 140 | console.log("background.js saw change to updateInterval: " + 141 | this.updateInterval); 142 | this.resetAlarm(); 143 | } 144 | 145 | // The user updated their API keys, so let's go update the badge. 146 | if (changes.services) { 147 | this.services = changes.services.newValue; 148 | console.log("background.js saw change to services"); 149 | this._ensureStatesForServices(); 150 | await this.updateBadge(); 151 | } 152 | } 153 | }, 154 | 155 | /** 156 | * Wipes out any pre-existing alarm and sets up a new one with 157 | * the current update interval. 158 | */ 159 | async resetAlarm() { 160 | let cleared = await browser.alarms.clear(ALARM_NAME); 161 | if (cleared) { 162 | console.log("Cleared old alarm"); 163 | } 164 | 165 | console.log("Resetting alarm - will fire in " + 166 | `${this.updateInterval} minutes`); 167 | browser.alarms.create(ALARM_NAME, { 168 | periodInMinutes: this.updateInterval, 169 | }); 170 | }, 171 | 172 | /** 173 | * Handles messages from the popup. 174 | */ 175 | onMessage(message, sender, sendReply) { 176 | switch (message.name) { 177 | case "get-states": { 178 | // The popup wants to know how many things there are to do. 179 | sendReply(this.states); 180 | break; 181 | } 182 | 183 | case "refresh": { 184 | this.updateBadge(); 185 | break; 186 | } 187 | 188 | case "get-feature-rev": { 189 | sendReply({ 190 | newFeatures: this.featureRev < FEATURE_ALERT_REV, 191 | featureRev: this.featureRev + 1, 192 | }); 193 | break; 194 | } 195 | 196 | case "opened-release-notes": { 197 | this.featureRev = FEATURE_ALERT_REV; 198 | browser.storage.local.set({ featureRev: this.featureRev, }); 199 | this.updateBadge(); 200 | break; 201 | } 202 | 203 | case "check-for-phabricator-session": { 204 | return this._hasPhabricatorSession(); 205 | } 206 | 207 | // Debug stuff 208 | case "get-phabricator-html": { 209 | console.debug("Getting Phabricator dashboard body"); 210 | return this._phabricatorDocumentBody(); 211 | } 212 | } 213 | }, 214 | 215 | /** 216 | * The alarm went off! Let's do the badge updating stuff now. 217 | */ 218 | onAlarm(alarmInfo) { 219 | if (alarmInfo.name == ALARM_NAME) { 220 | console.log("Updating the badge now..."); 221 | this.updateBadge(); 222 | } 223 | }, 224 | 225 | async updatePhabricator(settings) { 226 | if (settings.container === undefined) { 227 | // Phabricator is disabled. 228 | console.log("Phabricator service is disabled."); 229 | return { 230 | disabled: true, 231 | reviewTotal: 0, 232 | userReviewTotal: 0, 233 | groupReviewTotal: 0, 234 | }; 235 | } 236 | 237 | if (await this._hasPhabricatorCookie()) { 238 | console.log("Phabricator session found! Attempting to get dashboard " + 239 | "page."); 240 | 241 | let { 242 | ok, 243 | reviewTotal, 244 | userReviewTotal, 245 | groupReviewTotal, 246 | } = await this.phabricatorReviewRequests(); 247 | return { connected: ok, reviewTotal, userReviewTotal, groupReviewTotal, }; 248 | } else { 249 | console.log("No Phabricator session found. I won't try to fetch " + 250 | "anything for it."); 251 | return { 252 | connected: false, 253 | reviewTotal: 0, 254 | userReviewTotal: 0, 255 | groupReviewTotal: 0, 256 | }; 257 | } 258 | }, 259 | 260 | async _hasPhabricatorSession({ testingURL = null, } = {}) { 261 | if (await this._hasPhabricatorCookie()) { 262 | let { ok, } = await this._phabricatorDocumentBody({ testingURL, }); 263 | return ok; 264 | } 265 | 266 | return false; 267 | }, 268 | 269 | async _hasPhabricatorCookie() { 270 | let phabCookie = await browser.cookies.get({ 271 | url: PHABRICATOR_ROOT, 272 | name: "phsid", 273 | }); 274 | return !!phabCookie; 275 | }, 276 | 277 | async _phabricatorDocumentBody({ testingURL = null, } = {}) { 278 | let url = testingURL || 279 | [PHABRICATOR_ROOT, PHABRICATOR_DASHBOARD,].join("/"); 280 | 281 | let req = new Request(url, { 282 | method: "GET", 283 | headers: { 284 | "Content-Type": "text/html", 285 | }, 286 | redirect: "follow", 287 | }); 288 | 289 | let resp = await window.fetch(req); 290 | let ok = resp.ok; 291 | let pageBody = await resp.text(); 292 | return { ok, pageBody, }; 293 | }, 294 | 295 | async phabricatorReviewRequests({ testingURL = null, } = {}) { 296 | let { ok, pageBody, } = 297 | await this._phabricatorDocumentBody({ testingURL, }); 298 | let parser = new DOMParser(); 299 | let doc = parser.parseFromString(pageBody, "text/html"); 300 | 301 | let userMenu = 302 | doc.querySelector("a.phabricator-core-user-menu[href^='/p/']"); 303 | let userId = userMenu.href; 304 | 305 | let headers = doc.querySelectorAll(".phui-header-header"); 306 | let userReviewTotal = 0; 307 | let groupReviewTotal = 0; 308 | 309 | for (let header of headers) { 310 | if (PHABRICATOR_REVIEW_HEADERS.includes(header.textContent)) { 311 | let box = header.closest(".phui-box"); 312 | let rows = box.querySelectorAll(".phui-oi-table-row"); 313 | let localUserReviewTotal = 0; 314 | for (let row of rows) { 315 | let reviewers = row.querySelectorAll(".phui-link-person"); 316 | for (let reviewer of reviewers) { 317 | let reviewerId = reviewer.href; 318 | if (reviewerId == userId) { 319 | localUserReviewTotal++; 320 | break; 321 | } 322 | } 323 | } 324 | 325 | userReviewTotal += localUserReviewTotal; 326 | groupReviewTotal += rows.length - localUserReviewTotal; 327 | } 328 | } 329 | 330 | let reviewTotal = userReviewTotal; 331 | 332 | return { ok, reviewTotal, userReviewTotal, groupReviewTotal, }; 333 | }, 334 | 335 | async updateBugzilla(settings) { 336 | let apiKey = settings.apiKey; 337 | if (!apiKey) { 338 | return { reviewTotal: 0, needinfoTotal: 0, }; 339 | } 340 | 341 | // I'm not sure how much of this is necessary - I just looked at what 342 | // the Bugzilla My Dashboard thing does in the network inspector, and 343 | // I'm more or less mimicking that here. 344 | let body = JSON.stringify({ 345 | id: 4, 346 | method: "MyDashboard.run_flag_query", 347 | params: { 348 | Bugzilla_api_key: apiKey, 349 | type: "requestee", 350 | }, 351 | version: "1.1", 352 | }); 353 | 354 | let req = new Request(BUGZILLA_API, { 355 | method: "POST", 356 | headers: { 357 | "Content-Type": "application/json", 358 | }, 359 | body, 360 | credentials: "omit", 361 | redirect: "follow", 362 | referrer: "client", 363 | }); 364 | 365 | let resp = await window.fetch(req); 366 | let bugzillaData = await resp.json(); 367 | if (bugzillaData.error) { 368 | throw new Error(`Bugzilla request failed: ${bugzillaData.error.message}`); 369 | } 370 | let reviewTotal = 371 | bugzillaData.result.result.requestee.filter(f => { 372 | return f.type == "review"; 373 | }).length; 374 | 375 | let needinfoTotal = 0; 376 | if (settings.needinfos) { 377 | needinfoTotal =bugzillaData.result.result.requestee.filter(f => { 378 | return f.type == "needinfo"; 379 | }).length; 380 | } 381 | 382 | return { reviewTotal, needinfoTotal, }; 383 | }, 384 | 385 | async updateGitHub(settings) { 386 | let username = settings.username; 387 | let reviewUrl = new URL("https://github.com/pulls/review-requested"); 388 | 389 | if (!username) { 390 | return { reviewTotal: 0, reviewUrl: reviewUrl.toString(), }; 391 | } 392 | let token = settings.token; 393 | 394 | // We don't seem to need authentication for this request, for whatever 395 | // reason. 396 | let url = new URL(GITHUB_API); 397 | let query = `review-requested:${username} type:pr is:open archived:false`; 398 | if (settings.ignoreOwnPrs) { 399 | query += ` -author:${username}`; 400 | } 401 | if (settings.ignoreDraftPrs) { 402 | query += " draft:false"; 403 | } 404 | url.searchParams.set("q", query); 405 | reviewUrl.searchParams.set("q", query); 406 | reviewUrl = reviewUrl.toString(); 407 | 408 | let headers = { 409 | Accept: "application/vnd.github.v3+json", 410 | }; 411 | if (token) { 412 | headers["Authorization"] = `token ${token}`; 413 | } 414 | const apiRequestOptions = { 415 | method: "GET", 416 | headers: headers, 417 | // Probably doesn't matter. 418 | credentials: "omit", 419 | }; 420 | // Note: we might need to paginate if we care about fetching more than the 421 | // first 100. 422 | let response = await window.fetch(url, apiRequestOptions); 423 | if (!response.ok) { 424 | console.error("Failed to request from github", response); 425 | throw new Error(`Github request failed (${response.status}): ` + 426 | `${await response.text()}`); 427 | } 428 | const data = await response.json(); 429 | 430 | let ignoredTeams = new Set( 431 | (settings.ignoredTeams || "") 432 | .split(",") 433 | .map(s => s.trim()) 434 | .filter(Boolean)); 435 | 436 | let ignoredRepos = (settings.ignoredRepos || "") 437 | .split(",") 438 | .map(s => s.trim()) 439 | .filter(Boolean); 440 | 441 | if (ignoredTeams.size === 0 && ignoredRepos.length === 0) { 442 | return { reviewTotal: data.total_count, reviewUrl, }; 443 | } 444 | // Sadly, `-team-review-requested:` doesn't appear to work in the API, so we 445 | // just fetch each PR. Unfortunately, there's a rate limit of 60 requests 446 | // per hour associated with these (requiring an OAuth token would fix this 447 | // too). If we hit it, we stop respecting the ignore list. 448 | 449 | let hitRateLimit = false; 450 | // `items` may be a partial list. Ideally we'd paginate, but for now we just 451 | // assume everything in total_count that isn't part of items is important. 452 | let validPrs = data.total_count - data.items.length; 453 | for (let pr of data.items) { 454 | let prUrl = pr.pull_request.url; 455 | let reviewers = []; 456 | let teams = []; 457 | if (!hitRateLimit) { 458 | let resp = await window.fetch(prUrl, apiRequestOptions); 459 | let rateLimRemaining = resp.headers.get("X-RateLimit-Remaining"); 460 | if (rateLimRemaining === 0) { 461 | hitRateLimit = true; 462 | } else { 463 | if (resp.ok) { 464 | let respBody = await resp.json(); 465 | reviewers = respBody.requested_reviewers || []; 466 | teams = respBody.requested_teams || []; 467 | } else { 468 | // Don't treat a request failure here as fatal, just stop making 469 | // requests as if we hit the rate limit. 470 | console.error("Failed to request from github", response); 471 | hitRateLimit = true; 472 | } 473 | } 474 | } 475 | // If review was requested directly, always treat as a valid PR. 476 | if (reviewers.some(reviewer => reviewer.login === username)) { 477 | validPrs++; 478 | } else if (teams.every(team => !ignoredTeams.has(team.name)) && 479 | ignoredRepos.every(repo => !prUrl.includes(repo))) { 480 | validPrs++; 481 | } 482 | } 483 | return { reviewTotal: validPrs, reviewUrl, }; 484 | }, 485 | 486 | /** 487 | * Is the current time within the user's working hours (if enabled)? 488 | */ 489 | async isWorkingHours() { 490 | console.log("Checking working hours."); 491 | 492 | let { workingHours, } = await browser.storage.local.get("workingHours"); 493 | 494 | if (typeof workingHours === "undefined" || !workingHours.enabled) { 495 | console.log("Working hours are not enabled"); 496 | return true; 497 | } 498 | 499 | let currentTime = new Date(); 500 | 501 | // It's possible for the start or end time to be an empty string, if the 502 | // html5 time input had one empty field when a date checkbox was changed. 503 | // The time input is kind of tricky to use; it's easy to overlook the 504 | // am/pm chooser. Also, some people may just want to set days of the week, 505 | // not times of day. In these cases, just skip the missing time check. 506 | if (!workingHours.startTime) { 507 | console.log("Start time not set. Skipping start time check."); 508 | } else { 509 | let startTime = new Date(); 510 | let [startHours, startMinutes,] = workingHours.startTime.split(":"); 511 | startTime.setHours(startHours, startMinutes); 512 | if (currentTime < startTime) { 513 | console.log(`Current time (${currentTime.toLocaleTimeString()}) is ` + 514 | "earlier than start time " + 515 | `(${startTime.toLocaleTimeString()})`); 516 | return false; 517 | } 518 | } 519 | 520 | if (!workingHours.endTime) { 521 | console.log("End time not set. Skipping end time check."); 522 | } else { 523 | let endTime = new Date(); 524 | let [endHours, endMinutes,] = workingHours.endTime.split(":"); 525 | endTime.setHours(endHours, endMinutes); 526 | if (currentTime > endTime) { 527 | console.log(`Current time (${currentTime.toLocaleTimeString()}) is ` + 528 | "later than end time " + 529 | `(${endTime.toLocaleTimeString()})`); 530 | return false; 531 | } 532 | } 533 | 534 | // Unlike the times, workingHours.days should never be false-y: the days are 535 | // set via checkboxes, and if they are all unchecked, it'll be an empty 536 | // array (which is truthy). 537 | const days = { 538 | 0: "sunday", 539 | 1: "monday", 540 | 2: "tuesday", 541 | 3: "wednesday", 542 | 4: "thursday", 543 | 5: "friday", 544 | 6: "saturday", 545 | }; 546 | let currentDay = days[currentTime.getDay()]; 547 | if (!workingHours.days.includes(currentDay)) { 548 | console.log(`Current day (${currentDay}) is not one of the working ` + 549 | `days (${workingHours.days.join(", ")})`); 550 | return false; 551 | } 552 | 553 | console.log("Current time is within the working hours"); 554 | return true; 555 | }, 556 | 557 | _calculateBadgeTotal(states) { 558 | let total = 0; 559 | for (let [, state,] of states) { 560 | total += state.data.reviewTotal || 0; 561 | 562 | if (state.type == "bugzilla") { 563 | total += state.data.needinfoTotal || 0; 564 | } 565 | } 566 | 567 | return total; 568 | }, 569 | 570 | /** 571 | * Contacts Phabricator, Bugzilla, and Github (if the API keys for them 572 | * exist), and attempts to get a review count for each. 573 | */ 574 | async updateBadge() { 575 | for (let service of this.services) { 576 | let state = this.states.get(service.id); 577 | let data = state.data; 578 | 579 | try { 580 | switch (service.type) { 581 | case "phabricator": { 582 | data = await this.updatePhabricator(service.settings); 583 | console.log(`Found ${data.reviewTotal} user reviews, ` + 584 | `${data.groupReviewTotal} group reviews ` + 585 | "to do in Phabricator."); 586 | if (service.settings.inclReviewerGroups) { 587 | data.reviewTotal += data.groupReviewTotal; 588 | } 589 | break; 590 | } 591 | case "bugzilla": { 592 | data = await this.updateBugzilla(service.settings); 593 | console.log(`Found ${data.reviewTotal} Bugzilla reviews ` + 594 | "to do"); 595 | console.log(`Found ${data.needinfoTotal} Bugzilla needinfos ` + 596 | "to do"); 597 | break; 598 | } 599 | case "github": { 600 | data = await this.updateGitHub(service.settings); 601 | console.log(`Found ${data.reviewTotal} GitHub reviews to do`); 602 | break; 603 | } 604 | } 605 | } catch (e) { 606 | console.error(`Error when updating ${service.type}: `, e.toString()); 607 | } 608 | 609 | state.data = data; 610 | } 611 | 612 | let workingHours = await this.isWorkingHours(); 613 | if (!workingHours) { 614 | console.log("Current time is outside working hours. Hiding reviews."); 615 | browser.browserAction.setBadgeText({ text: null, }); 616 | return; 617 | } 618 | 619 | let thingsToDo = this._calculateBadgeTotal(this.states); 620 | 621 | console.log(`Found a total of ${thingsToDo} things to do`); 622 | if (!thingsToDo) { 623 | // Check to see if there are new features to notify the user about. 624 | // We intentionally only do this if there are new reviews to do. 625 | if (this.featureRev < FEATURE_ALERT_REV) { 626 | browser.browserAction.setBadgeBackgroundColor({ 627 | color: FEATURE_ALERT_BG_COLOR, 628 | }); 629 | browser.browserAction.setBadgeText({ text: FEATURE_ALERT_STRING, }); 630 | } else { 631 | browser.browserAction.setBadgeText({ text: null, }); 632 | } 633 | } else { 634 | // If we happened to set the background colour when alerting about 635 | // new features, clear that out now. 636 | browser.browserAction.setBadgeBackgroundColor({ 637 | color: null, 638 | }); 639 | browser.browserAction.setBadgeText({ text: String(thingsToDo), }); 640 | } 641 | }, 642 | }; 643 | 644 | // Hackily detect the sinon-chrome test framework. If we're inside it, 645 | // don't run init automatically. 646 | if (!browser.flush) { 647 | MyQOnly.init(); 648 | } 649 | -------------------------------------------------------------------------------- /addon/constants.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | 3 | const PHABRICATOR_ROOT = "https://phabricator.services.mozilla.com"; 4 | const PHABRICATOR_DASHBOARD = "differential/query/active/"; 5 | const PHABRICATOR_REVIEW_HEADERS = [ 6 | "Must Review", 7 | "Ready to Review", 8 | ]; 9 | const BUGZILLA_API = "https://bugzilla.mozilla.org/jsonrpc.cgi"; 10 | const GITHUB_API = "https://api.github.com/search/issues"; 11 | 12 | const DEFAULT_UPDATE_INTERVAL = 5; // minutes 13 | const ALARM_NAME = "check-for-updates"; 14 | 15 | // Anytime we want to alert the user about changes in the changelog, we should 16 | // bump the revision number here. 17 | const FEATURE_ALERT_REV = 3; 18 | const FEATURE_ALERT_BG_COLOR = "#EC9329"; 19 | const FEATURE_ALERT_STRING = "New"; -------------------------------------------------------------------------------- /addon/content/debug/debug.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | MyQOnly Debug Tools 7 | 8 | 9 | 10 |

MyQOnly Debug Tools

11 | 12 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /addon/content/debug/debug.js: -------------------------------------------------------------------------------- 1 | const Debug = { 2 | async init() { 3 | window.addEventListener("click", this); 4 | }, 5 | 6 | handleEvent(event) { 7 | switch (event.type) { 8 | case "click": { 9 | return this.onClick(event); 10 | } 11 | } 12 | }, 13 | 14 | onClick(event) { 15 | switch (event.target.id) { 16 | case "update": { 17 | browser.runtime.sendMessage({ name: "refresh", }); 18 | break; 19 | } 20 | case "generate-phabricator-testcase": { 21 | console.log("Generating Phabricator testcase..."); 22 | this.generatePhabricatorTestcase(); 23 | break; 24 | } 25 | case "show-services": { 26 | this.showServices(); 27 | break; 28 | } 29 | case "show-old-userKeys": { 30 | this.showOldUserKeys(); 31 | break; 32 | } 33 | } 34 | }, 35 | 36 | async generatePhabricatorTestcase() { 37 | let { pageBody, } = 38 | await browser.runtime.sendMessage({ name: "get-phabricator-html", }); 39 | let parser = new DOMParser(); 40 | let doc = parser.parseFromString(pageBody, "text/html"); 41 | 42 | // Clear out any of the titles and links for the revisions, to avoid 43 | // security-sensitive things getting captured. 44 | let links = doc.body.querySelectorAll(".phui-oi-link"); 45 | for (let link of links) { 46 | link.title = link.textContent = "Bug 123456 - This is some bug"; 47 | link.href = "#"; 48 | } 49 | 50 | let hiddenInputs = doc.body.querySelectorAll("input[type='hidden']"); 51 | for (let input of hiddenInputs) { 52 | input.remove(); 53 | } 54 | 55 | let outputEl = document.getElementById("phabricator-testcase"); 56 | outputEl.textContent = doc.body.innerHTML; 57 | }, 58 | 59 | async showServices() { 60 | let { services, } = await browser.storage.local.get("services"); 61 | let servicesEl = document.getElementById("services"); 62 | if (!services) { 63 | servicesEl.textContent = "Couldn't find any services"; 64 | } else { 65 | for (let service of services) { 66 | if (service.type == "bugzilla") { 67 | service.settings.apiKey = ""; 68 | } 69 | } 70 | servicesEl.textContent = JSON.stringify(services); 71 | } 72 | }, 73 | 74 | async showOldUserKeys() { 75 | let { oldUserKeys, } = await browser.storage.local.get("oldUserKeys"); 76 | let outputEl = document.getElementById("old-userKeys"); 77 | if (!oldUserKeys) { 78 | outputEl.textContent = "Couldn't find any old userKeys"; 79 | } else { 80 | if (oldUserKeys.bugzilla) { 81 | oldUserKeys.bugzilla = ""; 82 | } 83 | outputEl.textContent = JSON.stringify(oldUserKeys); 84 | } 85 | }, 86 | }; 87 | 88 | addEventListener("DOMContentLoaded", () => { 89 | Debug.init(); 90 | }, { once: true, }); 91 | -------------------------------------------------------------------------------- /addon/content/options/options.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: message-box; 3 | } 4 | 5 | h3 { 6 | margin-top: 1.2em; 7 | margin-bottom: 0.5em; 8 | } 9 | 10 | input[type="password"], 11 | input[type="text"] { 12 | width: 50ch; 13 | } 14 | 15 | label, .form-rows { 16 | margin-top: 5px; 17 | } 18 | 19 | .service-settings, 20 | .field-rows { 21 | display: grid; 22 | } 23 | 24 | .field-rows { 25 | column-gap: 0.5em; 26 | row-gap: 0.5em; 27 | grid-template-columns: max-content max-content; 28 | } 29 | 30 | #update-interval { 31 | max-width: 6ch; 32 | } 33 | 34 | #phabricator-session-status:not([has-session]) > span, 35 | #phabricator-session-status[has-session="true"] > span.no-session, 36 | #phabricator-session-status[has-session="false"] > span.yes-session { 37 | display: none; 38 | } 39 | 40 | #working-hours-fields[disabled] { 41 | opacity: 0.6; 42 | } 43 | 44 | section:not(:first-child) { 45 | margin-block-start: 2em; 46 | } 47 | 48 | #debug { 49 | display: inline-block; 50 | position: fixed; 51 | bottom: 0; 52 | right: 0; 53 | } 54 | 55 | #debug:not(:hover) { 56 | opacity: 0; 57 | } -------------------------------------------------------------------------------- /addon/content/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MyQOnly Options 8 | 9 | 10 | 11 |

MyQOnly Options

12 | 13 |
14 |

General

15 | 16 | 17 | 18 | minutes 19 | 20 |
21 | 22 |
23 |

Services

24 | 25 |

Phabricator

26 |
27 |
28 | 29 | 30 |
31 |
32 | 33 | 34 |
35 |

36 | Found a Phabricator session cookie in the default container 37 | Did not find a Phabricator session cookie. Are you logged in to Phabricator in the default container? 38 |

39 |
40 | 41 |

Bugzilla

42 |
43 | 44 | 45 |
46 | 47 | 48 |
49 |
50 | 51 |

GitHub

52 |
53 | 54 | 55 | 56 | 57 |
58 | 59 | 60 |
61 |
62 | 63 | 64 |
65 | 66 | 67 | 68 | 69 |
70 |
71 | 72 |
73 |

Working hours

74 | 75 | 76 | 77 |
78 |

Days

79 |
80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 |
101 | 102 |

Hours

103 |
104 | 105 | 106 | 107 | 108 | 109 |
110 |
111 |
112 | 113 |
114 |

Thanks for using MyQOnly! Bug reports, enhancement requests and pull requests welcome!

115 |

Release notes

116 |
117 | 118 | Debug 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /addon/content/options/options.js: -------------------------------------------------------------------------------- 1 | const Options = { 2 | _nextID: 0, 3 | 4 | async init() { 5 | console.log("Initting Options page"); 6 | 7 | console.debug("Getting update interval"); 8 | let { updateInterval, } = await browser.storage.local.get("updateInterval"); 9 | let interval = document.getElementById("update-interval"); 10 | interval.value = updateInterval; 11 | 12 | console.debug("Getting services"); 13 | let { services, } = await browser.storage.local.get("services"); 14 | this.services = services || []; 15 | 16 | console.debug("Populating form"); 17 | for (let service of this.services) { 18 | switch (service.type) { 19 | case "phabricator": { 20 | this.populatePhabricator(service); 21 | break; 22 | } 23 | case "bugzilla": { 24 | this.populateBugzilla(service); 25 | break; 26 | } 27 | case "github": { 28 | this.populateGitHub(service); 29 | break; 30 | } 31 | } 32 | this._nextID = Math.max(this._nextID, service.id); 33 | } 34 | 35 | this._nextID++; 36 | 37 | console.debug("Adding change event listener"); 38 | window.addEventListener("change", this); 39 | window.addEventListener("click", this); 40 | 41 | this.initWorkingHours(); 42 | let initted = new CustomEvent("initted", { bubbles: true, }); 43 | document.dispatchEvent(initted); 44 | }, 45 | 46 | populatePhabricator(service) { 47 | let phabricatorSettings = 48 | document.querySelector(".service-settings[data-type='phabricator']"); 49 | 50 | let container = 51 | phabricatorSettings.querySelector("[data-setting='container']"); 52 | container.checked = !!service.settings.container; 53 | 54 | let inclReviewerGroups = 55 | phabricatorSettings.querySelector("[data-setting='inclReviewerGroups']"); 56 | inclReviewerGroups.checked = !!service.settings.inclReviewerGroups; 57 | 58 | let sessionPromise = 59 | browser.runtime.sendMessage({ name: "check-for-phabricator-session", }); 60 | sessionPromise.then(hasSession => { 61 | let status = document.getElementById("phabricator-session-status"); 62 | status.setAttribute("has-session", hasSession); 63 | }); 64 | }, 65 | 66 | populateBugzilla(service) { 67 | let bugzillaSettings = 68 | document.querySelector(".service-settings[data-type='bugzilla']"); 69 | 70 | let apiKey = bugzillaSettings.querySelector("[data-setting='apiKey']"); 71 | apiKey.value = service.settings.apiKey; 72 | 73 | let needinfos = 74 | bugzillaSettings.querySelector("[data-setting='needinfos']"); 75 | needinfos.checked = !!service.settings.needinfos; 76 | }, 77 | 78 | populateGitHub(service) { 79 | let githubSettings = 80 | document.querySelector(".service-settings[data-type='github']"); 81 | 82 | let username = githubSettings.querySelector("[data-setting='username']"); 83 | username.value = service.settings.username; 84 | 85 | let token = githubSettings.querySelector("[data-setting='token']"); 86 | token.value = service.settings.token || ""; 87 | 88 | let ignoreOwnPrs = 89 | githubSettings.querySelector("[data-setting='ignoreOwnPrs']"); 90 | ignoreOwnPrs.checked = !!service.settings.ignoreOwnPrs; 91 | 92 | let ignoreDraftPrs = 93 | githubSettings.querySelector("[data-setting='ignoreDraftPrs']"); 94 | ignoreDraftPrs.checked = !!service.settings.ignoreDraftPrs; 95 | 96 | let ignoredTeams = 97 | githubSettings.querySelector("[data-setting='ignoredTeams']"); 98 | ignoredTeams.value = service.settings.ignoredTeams || ""; 99 | 100 | let ignoredRepos = 101 | githubSettings.querySelector("[data-setting='ignoredRepos']"); 102 | ignoredRepos.value = service.settings.ignoredRepos || ""; 103 | }, 104 | 105 | onUpdateService(event, serviceType) { 106 | let changedSetting = event.target.dataset.setting; 107 | let newValue; 108 | switch (event.target.type) { 109 | case "text": 110 | case "password": 111 | newValue = event.target.value; 112 | break; 113 | case "checkbox": 114 | if (event.target.checked) { 115 | if (event.target.hasAttribute("value")) { 116 | newValue = event.target.value; 117 | } else { 118 | newValue = true; 119 | } 120 | } else { 121 | newValue = null; 122 | } 123 | break; 124 | } 125 | 126 | // For now, there's only a single service instance per type. 127 | let settings = this.getServiceSettings(serviceType); 128 | if (newValue !== undefined) { 129 | settings[changedSetting] = newValue; 130 | } else { 131 | delete settings[changedSetting]; 132 | } 133 | 134 | browser.storage.local.set({ "services": this.services, }).then(() => { 135 | console.log(`Saved update to ${serviceType} setting ${changedSetting}`); 136 | }); 137 | }, 138 | 139 | getServiceSettings(serviceType) { 140 | for (let instance of this.services) { 141 | if (instance.type == serviceType) { 142 | return instance.settings; 143 | } 144 | } 145 | 146 | let settings = {}; 147 | // We've never saved a value here before. Let's create a new one. 148 | this.services.push({ 149 | id: this._nextID++, 150 | type: serviceType, 151 | settings, 152 | }); 153 | 154 | return settings; 155 | }, 156 | 157 | async initWorkingHours() { 158 | // Specify reasonable defaults for the first-run case. 159 | let { workingHours, } = await browser.storage.local.get({workingHours: { 160 | enabled: false, 161 | startTime: "09:00", 162 | endTime: "17:00", 163 | days: ["monday","tuesday","wednesday","thursday","friday",], 164 | },}); 165 | 166 | let workingHoursSection = document.querySelector("#working-hours"); 167 | let fields = workingHoursSection.querySelector("#working-hours-fields"); 168 | workingHoursSection.querySelector("#working-hours-checkbox").checked = 169 | workingHours.enabled; 170 | 171 | if (workingHours.enabled) { 172 | fields.removeAttribute("disabled"); 173 | } else { 174 | fields.setAttribute("disabled", "disabled"); 175 | } 176 | 177 | document.querySelector("#start-time").value = workingHours.startTime; 178 | document.querySelector("#end-time").value = workingHours.endTime; 179 | 180 | let dayEls = fields.querySelectorAll(".days > input[type='checkbox']"); 181 | for (let dayEl of dayEls) { 182 | dayEl.checked = workingHours.days.includes(dayEl.id); 183 | } 184 | }, 185 | 186 | handleEvent(event) { 187 | switch (event.type) { 188 | case "click": { 189 | return this.onClick(event); 190 | } 191 | case "change": { 192 | return this.onChange(event); 193 | } 194 | } 195 | }, 196 | 197 | onClick(event) { 198 | switch (event.target.id) { 199 | case "debug": { 200 | browser.tabs.create({ 201 | url: event.target.href, 202 | }); 203 | event.preventDefault(); 204 | return false; 205 | } 206 | case "working-hours-checkbox": { 207 | this.onWorkingHoursChanged(); 208 | break; 209 | } 210 | } 211 | }, 212 | 213 | onChange(event) { 214 | // Are we updating a service? 215 | let serviceSettings = event.target.closest(".service-settings"); 216 | if (serviceSettings) { 217 | return this.onUpdateService(event, serviceSettings.dataset.type); 218 | } 219 | 220 | if (event.target.id == "update-interval") { 221 | let updateInterval = parseInt(event.target.value, 10); 222 | browser.storage.local.set({ updateInterval, }).then(() => { 223 | console.log(`Saved update interval as ${updateInterval} minutes`); 224 | }); 225 | } else if (event.target.closest("#working-hours-fields")) { 226 | this.onWorkingHoursChanged(); 227 | } 228 | }, 229 | 230 | onWorkingHoursChanged() { 231 | console.log("Working hours changed"); 232 | 233 | let enabled = document.querySelector("#working-hours-checkbox").checked; 234 | if (enabled) { 235 | document.querySelector("#working-hours-fields") 236 | .removeAttribute("disabled"); 237 | } else { 238 | document.querySelector("#working-hours-fields") 239 | .setAttribute("disabled", "disabled"); 240 | } 241 | 242 | // Times are strings of the form "HH:MM" in 24-hour format (or empty string) 243 | let startTime = document.querySelector("#start-time").value; 244 | let endTime = document.querySelector("#end-time").value; 245 | 246 | // `days` is an array containing en-US day strings: 247 | // ['sunday', 'monday', ...] 248 | let days = [].slice.call(document.querySelectorAll(".days > input:checked")) 249 | .map(el => { return el.getAttribute("id");}); 250 | 251 | browser.storage.local.set({ 252 | workingHours: { 253 | enabled, 254 | days, 255 | startTime, 256 | endTime, 257 | }, 258 | }).then(() => { 259 | console.log(`Saved update to working hours: enabled: ${enabled}, ` + 260 | `days: ${days.join(",")}, start time: ${startTime}, ` + 261 | `end time: ${endTime}`); 262 | }).catch((err) => { 263 | console.error(`Error updating working hours: ${err}`); 264 | }); 265 | }, 266 | }; 267 | 268 | addEventListener("DOMContentLoaded", () => { 269 | Options.init(); 270 | }, { once: true, }); 271 | -------------------------------------------------------------------------------- /addon/content/popup/popup.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font: message-box; 3 | width: 250px; 4 | margin: 0; 5 | } 6 | 7 | header { 8 | display: grid; 9 | grid-template-columns: min-content 1fr min-content; 10 | height: 32px; 11 | border-bottom: 1px solid #444444; 12 | background-color: #777777; 13 | color: #FFFFFF; 14 | } 15 | 16 | #phabricator-disconnected { 17 | height: 15px; 18 | background-color: #4a5f88; 19 | color: #f9ffff; 20 | border-bottom: 1px solid #444444; 21 | text-align: center; 22 | } 23 | 24 | .icon { 25 | height: 23px; 26 | width: 23px; 27 | margin: 5px; 28 | cursor: pointer; 29 | background-repeat: no-repeat; 30 | background-size: 23px; 31 | } 32 | 33 | .hidden { 34 | display: none; 35 | } 36 | 37 | #status { 38 | text-align: center; 39 | vertical-align: text-bottom; 40 | margin-top: auto; 41 | margin-bottom: auto; 42 | } 43 | 44 | #refresh { 45 | background-image: url('/icons/refresh.svg'); 46 | border: none; 47 | background-color: transparent; 48 | } 49 | 50 | #options { 51 | background-image: url('/icons/gear.svg'); 52 | } 53 | 54 | body[total-phabricator-reviews="0"] > #phabricator-reviews, 55 | body[total-bugzilla-reviews="0"] > #bugzilla-reviews, 56 | body[total-bugzilla-needinfos="0"] > #bugzilla-needinfos, 57 | body[total-github-reviews="0"] > #github-reviews, 58 | body:not([has-new-features]) > #has-new-features { 59 | display: none; 60 | } 61 | 62 | body > section, 63 | body > h1 { 64 | margin: 15px; 65 | } 66 | 67 | #has-new-features { 68 | display: block; 69 | padding: 5px; 70 | background-color: #DB8218; 71 | border-top: #BD7621; 72 | color: #FFFFFF; 73 | text-align: center; 74 | text-decoration: none; 75 | } 76 | -------------------------------------------------------------------------------- /addon/content/popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 |
17 | review(s) on Phabricator 18 |
19 | 20 |
21 | reviewer group review(s) on Phabricator 22 |
23 | 24 |
25 | review(s) on Bugzilla 26 |
27 | 28 |
29 | needinfo(s) on Bugzilla 30 |
31 | 32 |
33 | review(s) on Github 34 |
35 | 36 | 37 | New features 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /addon/content/popup/popup.js: -------------------------------------------------------------------------------- 1 | const Panel = { 2 | async init() { 3 | let { newFeatures, featureRev, } = 4 | await browser.runtime.sendMessage({ name: "get-feature-rev", }); 5 | if (newFeatures) { 6 | document.body.setAttribute("has-new-features", featureRev); 7 | let link = document.getElementById("has-new-features"); 8 | link.href = link.href + "#featureRev-" + featureRev; 9 | } 10 | 11 | window.addEventListener("click", this); 12 | await this.updatePanel(); 13 | }, 14 | 15 | handleEvent(event) { 16 | switch (event.type) { 17 | case "click": { 18 | this.onClick(event); 19 | break; 20 | } 21 | } 22 | }, 23 | 24 | onClick(event) { 25 | switch (event.target.id) { 26 | case "has-new-features": { 27 | browser.tabs.create({ 28 | url: event.target.href, 29 | }); 30 | browser.runtime.sendMessage({ name: "opened-release-notes", }); 31 | event.preventDefault(); 32 | window.close(); 33 | return false; 34 | } 35 | case "refresh": { 36 | this.refresh(); 37 | return false; 38 | } 39 | case "options": { 40 | browser.runtime.openOptionsPage(); 41 | event.preventDefault(); 42 | window.close(); 43 | return false; 44 | } 45 | } 46 | }, 47 | 48 | async refresh() { 49 | let status = document.getElementById("status"); 50 | status.textContent = "Refreshing..."; 51 | 52 | let refreshPromise = browser.runtime.sendMessage({ name: "refresh", }); 53 | let visualDelayPromise = new Promise(resolve => setTimeout(resolve, 250)); 54 | await Promise.all([refreshPromise, visualDelayPromise,]); 55 | 56 | await this.updatePanel(); 57 | }, 58 | 59 | async updatePanel() { 60 | let status = document.getElementById("status"); 61 | let states = await browser.runtime.sendMessage({ name: "get-states", }); 62 | let total = 0; 63 | for (let [, state,] of states) { 64 | switch (state.type) { 65 | case "bugzilla": { 66 | let serviceTotal = 0; 67 | if (state.data.reviewTotal) { 68 | serviceTotal += state.data.reviewTotal; 69 | } 70 | if (state.data.needinfoTotal) { 71 | serviceTotal += state.data.needinfoTotal; 72 | } 73 | 74 | document.body.setAttribute("total-bugzilla-reviews", 75 | state.data.reviewTotal || 0); 76 | document.body.setAttribute("total-bugzilla-needinfos", 77 | state.data.needinfoTotal || 0); 78 | document.getElementById("bugzilla-review-num").textContent = 79 | state.data.reviewTotal || 0; 80 | document.getElementById("bugzilla-needinfo-num").textContent = 81 | state.data.needinfoTotal || 0; 82 | 83 | total += serviceTotal; 84 | break; 85 | } 86 | case "phabricator": { 87 | // If Phabricator is disabled, well, just skip. 88 | if (state.data.disabled) { 89 | continue; 90 | } 91 | 92 | let phabDisconnected = 93 | document.getElementById("phabricator-disconnected"); 94 | if (!state.data.connected) { 95 | phabDisconnected.classList.remove("hidden"); 96 | } else { 97 | phabDisconnected.classList.add("hidden"); 98 | } 99 | 100 | let serviceUserTotal = state.data.userReviewTotal || 0; 101 | 102 | document.body.setAttribute("total-phabricator-user-reviews", 103 | serviceUserTotal || 0); 104 | document.getElementById("phabricator-user-review-num").textContent = 105 | serviceUserTotal || 0; 106 | 107 | let serviceGroupTotal = state.data.groupReviewTotal || 0; 108 | 109 | document.body.setAttribute("total-phabricator-group-reviews", 110 | serviceGroupTotal || 0); 111 | document.getElementById("phabricator-group-review-num").textContent = 112 | serviceGroupTotal || 0; 113 | document.getElementById("phabricator-group-reviews").hidden = 114 | serviceGroupTotal == 0; 115 | 116 | let serviceTotal = state.data.reviewTotal || 0; 117 | total += serviceTotal; 118 | break; 119 | } 120 | case "github": { 121 | let serviceTotal = state.data.reviewTotal || 0; 122 | let reviewUrl = state.data.reviewUrl || "https://github.com/pulls/review-requested"; 123 | document.body.setAttribute("total-github-reviews", 124 | serviceTotal || 0); 125 | document.getElementById("github-review-num").textContent = 126 | serviceTotal || 0; 127 | document.getElementById("github-review-link").href = reviewUrl; 128 | 129 | total += serviceTotal; 130 | break; 131 | } 132 | } 133 | } 134 | 135 | if (total) { 136 | let noun = total > 1 ? "things" : "thing"; 137 | status.textContent = `Found ${total} ${noun} to do`; 138 | } else { 139 | status.textContent = "Nothing to do! \\o/"; 140 | } 141 | }, 142 | }; 143 | 144 | addEventListener("DOMContentLoaded", () => { 145 | Panel.init(); 146 | }, { once: true, }); 147 | -------------------------------------------------------------------------------- /addon/content/release-notes/0.5/panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeconley/myqonly/39a68e3dd443afb3c2b340f167410af54d4a482a/addon/content/release-notes/0.5/panel.png -------------------------------------------------------------------------------- /addon/content/release-notes/0.5/working-hours.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeconley/myqonly/39a68e3dd443afb3c2b340f167410af54d4a482a/addon/content/release-notes/0.5/working-hours.png -------------------------------------------------------------------------------- /addon/content/release-notes/0.6/needinfos-in-options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeconley/myqonly/39a68e3dd443afb3c2b340f167410af54d4a482a/addon/content/release-notes/0.6/needinfos-in-options.png -------------------------------------------------------------------------------- /addon/content/release-notes/0.6/needinfos-in-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeconley/myqonly/39a68e3dd443afb3c2b340f167410af54d4a482a/addon/content/release-notes/0.6/needinfos-in-panel.png -------------------------------------------------------------------------------- /addon/content/release-notes/0.6/no-phabricator-session.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeconley/myqonly/39a68e3dd443afb3c2b340f167410af54d4a482a/addon/content/release-notes/0.6/no-phabricator-session.png -------------------------------------------------------------------------------- /addon/content/release-notes/release-notes.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font: message-box; 3 | font-size: 1em; 4 | } 5 | 6 | img { 7 | display: block; 8 | box-shadow: 2px 2px 2px 1px #AAAAAA; 9 | margin: 20px 0px; 10 | } 11 | 12 | section:not(:nth-child(2)) { 13 | margin-top: 4em; 14 | } 15 | -------------------------------------------------------------------------------- /addon/content/release-notes/release-notes.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MyQOnly Release Notes 8 | 9 | 10 |

MyQOnly Release Notes

11 | 12 |
13 |

0.10.0

14 |

New features

15 |
    16 |
  • There is now an option to ignore draft pull requests from GitHub. Thanks to Andrew Halberstadt for the patch!
  • 17 |
18 |

Known issues

19 | 22 |
23 | 24 |
25 |

0.9.1

26 |

Bug fixes

27 |
    28 |
  • Fixed an issue where setting an empty string as the list of ignored GitHub team repositories would cause all GitHub repositories to be hidden. Thanks to Andrew Halberstadt for the patch!
  • 29 |
30 |

Known issues

31 | 34 |
35 | 36 |
37 |

0.9.0

38 |

New features

39 |
    40 |
  • It's now possible to ignore a GitHub team repository by using just a substring of the URL. This means mozilla-mobile/fenix would skip reviews for that repo, and mozilla-mobile would skip reviews for that entire org. Thanks to Andrew Halberstadt for the patch!
  • 41 |
  • GitHub API tokens can now be set in the Options page for private repositories! Thanks to Justin Wood for the patch!
  • 42 |
43 |

Changes

44 | 47 |

Bug fixes

48 | 52 |

Known issues

53 | 56 |
57 | 58 |
59 |

0.8.2

60 |

Bug fixes

61 |
    62 |
  • Updated icons to meet new requirements on addons.mozilla.org.
  • 63 |
64 |

Known issues

65 | 68 |
69 | 70 |
71 |

0.8

72 |

New features

73 |
    74 |
  • It's now possible to filter out group reviews from your review count. Look for the "Include reviewer groups in badge count" checkbox in the Options interface. Thanks to Aaron Klotz for the patch!
  • 75 |
76 |

Known issues

77 | 80 |
81 | 82 |
83 |

0.7.1

84 |

Bug fixes

85 |
    86 |
  • Fixed the "activeRevisions is null" bug in the Phabricator test case debug tool.
  • 87 |
88 |

Known issues

89 | 92 |
93 | 94 |
95 |

0.7

96 |

New features

97 |
    98 |
  • GitHub pull requests from yourself or specific teams can be filtered out. Thanks to thomcc for the patch!
  • 99 |
100 |

Bug fixes

101 |
    102 |
  • Worked around a grammar issue for singular reviews / needinfos in the panel.
  • 103 |
  • If GitHub usernames or Bugzilla API keys are cleared after originally being set, doesn't attempt to query those services
  • 104 |
  • Improved some spacing in the Options interface
  • 105 |
106 |

Known issues

107 | 110 |
111 | 112 |
113 |

0.6.1

114 |

Bug fixes

115 |
    116 |
  • Fixed a bug where some services would get the same IDs.
  • 117 |
118 |

Known issues

119 | 122 |
123 | 124 |
125 |

0.6

126 |

New features

127 | 134 |

Bug fixes

135 | 141 |

Known issues

142 | 145 |
146 | 147 |
148 |

0.5.4

149 |

Bug fixes

150 |
    151 |
  • Skipped 0.5.3 due to an AMO upload snafu.
  • 152 |
  • Migrating the storage schema to increase flexibility for upcoming features.
  • 153 |
154 |

Known issues

155 | 159 |
160 | 161 |
162 |

0.5.2

163 |

Bug fixes

164 |
    165 |
  • Fixed the Bugzilla API key input so that it properly updates the configuration now.
  • 166 |
167 |

Known issues

168 | 172 |
173 | 174 |
175 |

0.5.1

176 |

Bug fixes

177 |
    178 |
  • Fixed the refresh button so that it actually refreshes the count by asking services for updated counts. How embarrassing!
  • 179 |
  • Added a minimum delay of 250ms to the refresh state in the popup. Thanks to jhirsch for the patch!
  • 180 |
181 |

Known issues

182 | 186 |
187 | 188 |
189 |

0.5

190 |

New features

191 |
    192 |
  • Redesigned the panel. The panel now has a button to refresh the review count, as well as a shortcut to the add-on options page. 193 | 194 |
  • 195 |
  • Added working hours support. Choose which windows of time you want to be informed about your review queue. Thanks to jhirsch for the initial implementation! 196 | 197 |
  • 198 |
199 |

Bug fixes

200 |
    201 |
  • Made the Bugzilla API key input a password field
  • 202 |
  • Added a simple, and hopefully unobtrusive new feature notification system. To be used sparingly.
  • 203 |
  • Added a very simple debugging tool for generating Phabricator test cases (hover your mouse over the bottom-right part of the options page).
  • 204 |
205 |

Known issues

206 | 210 |
211 | 212 | 213 | 214 | -------------------------------------------------------------------------------- /addon/icons/CREDITS.md: -------------------------------------------------------------------------------- 1 | analyze by Nithinan Tatah from the Noun Project 2 | https://thenounproject.com/term/analyze/1584256 3 | -------------------------------------------------------------------------------- /addon/icons/gear.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /addon/icons/myqonly-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /addon/icons/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /addon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "A tool for Mozillians who want to know how many reviews are in their queue in their browser.", 3 | "manifest_version": 2, 4 | "name": "MyQOnly", 5 | "version": "0.10.0", 6 | "background": { 7 | "scripts": [ 8 | "constants.js", 9 | "background.js" 10 | ] 11 | }, 12 | "browser_action": { 13 | "default_popup": "content/popup/popup.html", 14 | "browser_style": true, 15 | "default_title": "MyQOnly", 16 | "default_icon": { 17 | "16": "icons/myqonly-dark.svg", 18 | "32": "icons/myqonly-dark.svg" 19 | }, 20 | "theme_icons": [{ 21 | "light": "icons/myqonly-light.svg", 22 | "dark": "icons/myqonly-dark.svg", 23 | "size": 16 24 | }, { 25 | "light": "icons/myqonly-light.svg", 26 | "dark": "icons/myqonly-dark.svg", 27 | "size": 32 28 | }] 29 | }, 30 | "applications": { 31 | "gecko": { 32 | "id": "myqonly@mikeconley.ca", 33 | "strict_min_version": "62.0" 34 | } 35 | }, 36 | "options_ui": { 37 | "open_in_tab": true, 38 | "page": "content/options/options.html" 39 | }, 40 | "permissions": [ 41 | "alarms", 42 | "cookies", 43 | "storage", 44 | "https://phabricator.services.mozilla.com/*", 45 | "https://bugzilla.mozilla.org/*" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = function(config) { 3 | config.set({ 4 | 5 | // base path that will be used to resolve all patterns (eg. files, exclude) 6 | basePath: "", 7 | 8 | // frameworks to use 9 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 10 | frameworks: ["mocha", "chai",], 11 | 12 | // list of files / patterns to load in the browser 13 | files: [ 14 | // Test dependencies 15 | "node_modules/expect.js/index.js", 16 | "node_modules/sinon-chrome/bundle/sinon-chrome-webextensions.min.js", 17 | 18 | // Source to test 19 | "addon/*.js", 20 | { pattern: "addon/content/*/*.*", served: true, included: false, }, 21 | 22 | // Tests 23 | "tests/*.js", 24 | "tests/content/*.js", 25 | "tests/services/**/*.js", 26 | 27 | // Service test files 28 | { pattern: "tests/services/**/*.html", served: true, included: false, }, 29 | ], 30 | 31 | client: { 32 | mocha: { 33 | // change Karma's debug.html to the mocha web reporter 34 | reporter: "html", 35 | }, 36 | }, 37 | 38 | // preprocess matching files before serving them to the browser 39 | // available preprocessors: 40 | // https://npmjs.org/browse/keyword/karma-preprocessor 41 | preprocessors: { 42 | }, 43 | 44 | // test results reporter to use 45 | // possible values: 'dots', 'progress' 46 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 47 | reporters: ["dots",], 48 | 49 | // web server port 50 | port: 9876, 51 | 52 | // enable / disable colors in the output (reporters and logs) 53 | colors: true, 54 | 55 | // level of logging 56 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || 57 | // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 58 | logLevel: config.LOG_INFO, 59 | 60 | // enable/disable watching file and executing tests when any file changes 61 | autoWatch: false, 62 | 63 | // start these browsers 64 | // available browser launchers: 65 | // https://npmjs.org/browse/keyword/karma-launcher 66 | browsers: ["Firefox",], 67 | 68 | // Continuous Integration mode 69 | // if true, Karma captures browsers, runs the tests and exits 70 | singleRun: true, 71 | 72 | // Concurrency level 73 | // how many browser should be started simultaneous 74 | concurrency: Infinity, 75 | }); 76 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "myqonly", 3 | "version": "0.10.0", 4 | "description": "A tool for Mozillians who want to know how many reviews are in their queue in their browser.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "karma start", 8 | "test:manual": "web-ext run -s ./addon", 9 | "build": "web-ext build -s ./addon", 10 | "lint": "./node_modules/eslint/bin/eslint.js .", 11 | "lint-file": "./node_modules/eslint/bin/eslint.js" 12 | }, 13 | "repository": "git@github.com:mikeconley/myqonly.git", 14 | "author": "Mike Conley ", 15 | "license": "MPL-2.0", 16 | "devDependencies": { 17 | "chai": "^4.3.7", 18 | "eslint": "^8.57.0", 19 | "expect.js": "^0.3.1", 20 | "karma": "^6.3.14", 21 | "karma-chai": "^0.1.0", 22 | "karma-firefox-launcher": "^2.1.2", 23 | "karma-mocha": "^2.0.1", 24 | "mocha": "^10.3.0", 25 | "sinon-chrome": "^3.0.1", 26 | "web-ext": "^7.11.0" 27 | }, 28 | "dependencies": { 29 | "js-yaml": "4.1.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/.eslintrc.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | "extends": ["../.eslintrc.js", "../addon/.eslintrc.js"], 5 | 6 | "globals": { 7 | "describe": true, 8 | "beforeEach": true, 9 | "afterEach": true, 10 | "it": true, 11 | "assert": true, 12 | "should": true, 13 | "browser": true, 14 | "chrome": true, 15 | "MyQOnly": true, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /tests/background.test.js: -------------------------------------------------------------------------------- 1 | describe("MyQOnly initting fresh", function() { 2 | beforeEach(async function() { 3 | browser.storage.local.get.withArgs("featureRev").returns( 4 | Promise.resolve({}) 5 | ); 6 | browser.storage.local.set.returns( 7 | Promise.resolve({}) 8 | ); 9 | browser.storage.local.get.withArgs("updateInterval").returns( 10 | Promise.resolve({}) 11 | ); 12 | browser.storage.local.get.withArgs("services").returns( 13 | Promise.resolve({}) 14 | ); 15 | browser.storage.local.get.withArgs("workingHours").returns( 16 | Promise.resolve({}) 17 | ); 18 | }); 19 | 20 | afterEach(async function() { 21 | MyQOnly.uninit(); 22 | browser.flush(); 23 | }); 24 | 25 | it("should exist, and be able to init with defaults", async () => { 26 | should.exist(MyQOnly); 27 | await MyQOnly.init(); 28 | // Should have set up listeners and alarms 29 | assert.ok(browser.storage.onChanged.addListener.calledOnce); 30 | assert.ok(browser.alarms.onAlarm.addListener.calledOnce); 31 | assert.ok(browser.runtime.onMessage.addListener.calledOnce); 32 | 33 | assert.equal(MyQOnly.featureRev, FEATURE_ALERT_REV); 34 | assert.equal(MyQOnly.updateInterval, DEFAULT_UPDATE_INTERVAL); 35 | 36 | // We should default with the Phabricator service enabled 37 | assert.equal(MyQOnly.services.length, 1); 38 | assert.equal(MyQOnly.services[0].type, "phabricator"); 39 | 40 | for (let service in MyQOnly.reviewTotals) { 41 | assert.equal(MyQOnly.reviewTotals[service], 0); 42 | } 43 | 44 | assert.ok(browser.storage.local.set.calledWith({ 45 | featureRev: FEATURE_ALERT_REV, 46 | })); 47 | assert.ok(browser.storage.local.set.calledWith({ 48 | updateInterval: DEFAULT_UPDATE_INTERVAL, 49 | })); 50 | assert.ok(browser.alarms.create.calledWith(ALARM_NAME, { 51 | periodInMinutes: DEFAULT_UPDATE_INTERVAL, 52 | })); 53 | }); 54 | 55 | it("should give unique IDs when adding the Phabricator service", async () => { 56 | browser.storage.local.get.withArgs("services").returns( 57 | Promise.resolve({ 58 | services: [{ 59 | id: 1, 60 | type: "bugzilla", 61 | settings: { 62 | apiKey: "abc123", 63 | }, 64 | }, { 65 | id: 2, 66 | type: "github", 67 | settings: { 68 | username: "mikeconley", 69 | }, 70 | },], 71 | }) 72 | ); 73 | 74 | await MyQOnly.init(); 75 | 76 | assert.ok(browser.storage.local.set.calledWith({ 77 | services: [{ 78 | id: 1, 79 | type: "bugzilla", 80 | settings: { 81 | apiKey: "abc123", 82 | }, 83 | }, { 84 | id: 2, 85 | type: "github", 86 | settings: { 87 | username: "mikeconley", 88 | }, 89 | }, { 90 | id: 3, 91 | type: "phabricator", 92 | settings: { 93 | container: 0, 94 | inclReviewerGroups: true, 95 | }, 96 | },], 97 | })); 98 | }); 99 | 100 | it("should add the default Phabricator service for " + 101 | "new installs", async () => { 102 | browser.storage.local.get.withArgs("services").returns( 103 | Promise.resolve({}) 104 | ); 105 | 106 | await MyQOnly.init(); 107 | 108 | assert.ok(browser.storage.local.set.calledWith({ 109 | services: [{ 110 | id: 1, 111 | type: "phabricator", 112 | settings: { 113 | container: 0, 114 | inclReviewerGroups: true, 115 | }, 116 | },], 117 | })); 118 | }); 119 | 120 | it("should default the Phabricator service to show the " + 121 | "review group count.", async () => { 122 | browser.storage.local.get.withArgs("services").returns( 123 | Promise.resolve({ 124 | services: [{ 125 | id: 1, 126 | type: "bugzilla", 127 | settings: { 128 | apiKey: "abc123", 129 | }, 130 | }, { 131 | id: 2, 132 | type: "github", 133 | settings: { 134 | username: "mikeconley", 135 | }, 136 | },{ 137 | id: 3, 138 | type: "phabricator", 139 | settings: { 140 | container: 0, 141 | }, 142 | },], 143 | }) 144 | ); 145 | 146 | await MyQOnly.init(); 147 | 148 | assert.ok(browser.storage.local.set.calledWith({ 149 | services: [{ 150 | id: 1, 151 | type: "bugzilla", 152 | settings: { 153 | apiKey: "abc123", 154 | }, 155 | }, { 156 | id: 2, 157 | type: "github", 158 | settings: { 159 | username: "mikeconley", 160 | }, 161 | }, { 162 | id: 3, 163 | type: "phabricator", 164 | settings: { 165 | container: 0, 166 | inclReviewerGroups: true, 167 | }, 168 | },], 169 | })); 170 | }); 171 | 172 | it("should not update review group configuration for Phabricator " + 173 | "if it was already set.", async () => { 174 | browser.storage.local.get.withArgs("services").returns( 175 | Promise.resolve({ 176 | services: [{ 177 | id: 1, 178 | type: "bugzilla", 179 | settings: { 180 | apiKey: "abc123", 181 | }, 182 | }, { 183 | id: 2, 184 | type: "github", 185 | settings: { 186 | username: "mikeconley", 187 | }, 188 | },{ 189 | id: 3, 190 | type: "phabricator", 191 | settings: { 192 | container: 0, 193 | inclReviewerGroups: false, 194 | }, 195 | },], 196 | }) 197 | ); 198 | 199 | await MyQOnly.init(); 200 | 201 | let service = MyQOnly._getService("phabricator"); 202 | assert(!service.settings.inclReviewerGroups); 203 | }); 204 | }); 205 | -------------------------------------------------------------------------------- /tests/content/options.test.js: -------------------------------------------------------------------------------- 1 | /* globals loadPage, changeFieldValue */ 2 | 3 | /** 4 | * Prepares the Options UI so that it's in the default empty state. 5 | */ 6 | async function setupBlank(browser) { 7 | browser.storage.local.get.withArgs("updateInterval").returns( 8 | Promise.resolve({ updateInterval: DEFAULT_UPDATE_INTERVAL, }) 9 | ); 10 | browser.storage.local.get.withArgs("services").returns( 11 | Promise.resolve({ 12 | services: [{ 13 | id: 1, 14 | type: "phabricator", 15 | settings: { 16 | container: 0, 17 | inclReviewerGroups: true, 18 | }, 19 | },], 20 | }) 21 | ); 22 | browser.storage.local.get.withArgs({ workingHours: {}, }).returns( 23 | Promise.resolve({}) 24 | ); 25 | browser.runtime.sendMessage.withArgs({ 26 | name: "check-for-phabricator-session", 27 | }).returns( 28 | Promise.resolve(false) 29 | ); 30 | } 31 | 32 | async function setupWithServices(browser) { 33 | browser.storage.local.get.withArgs("updateInterval").returns( 34 | Promise.resolve({ updateInterval: DEFAULT_UPDATE_INTERVAL, }) 35 | ); 36 | browser.storage.local.get.withArgs("services").returns( 37 | Promise.resolve({ 38 | services: [{ 39 | id: 1, 40 | type: "bugzilla", 41 | settings: { 42 | apiKey: "abc123", 43 | }, 44 | }, { 45 | id: 2, 46 | type: "github", 47 | settings: { 48 | username: "mikeconley", 49 | }, 50 | },{ 51 | id: 3, 52 | type: "phabricator", 53 | settings: { 54 | container: 0, 55 | inclReviewerGroups: true, 56 | }, 57 | },], 58 | }) 59 | ); 60 | browser.storage.local.get.withArgs({ workingHours: {}, }).returns( 61 | Promise.resolve({}) 62 | ); 63 | browser.runtime.sendMessage.withArgs({ 64 | name: "check-for-phabricator-session", 65 | }).returns( 66 | Promise.resolve(false) 67 | ); 68 | } 69 | 70 | 71 | describe("Options page", function() { 72 | it("should show stored interval time, and be able to update", async () => { 73 | await loadPage({ 74 | url: "/addon/content/options/options.html", 75 | setup: setupBlank, 76 | test: async(content, document) => { 77 | let field = document.getElementById("update-interval"); 78 | parseInt(field.value, 10).should.equal(DEFAULT_UPDATE_INTERVAL); 79 | 80 | // Now update the value 81 | let newInterval = DEFAULT_UPDATE_INTERVAL + 1; 82 | browser.storage.local.set.withArgs({ 83 | updateInterval: undefined, 84 | }).returns( 85 | Promise.resolve() 86 | ); 87 | changeFieldValue(field, newInterval); 88 | assert.ok(browser.storage.local.set.calledOnce); 89 | assert.ok(browser.storage.local.set.calledWith({ 90 | updateInterval: newInterval, 91 | })); 92 | }, 93 | }); 94 | }); 95 | }); 96 | 97 | describe("Options page", function() { 98 | it("should show and be able to update the Bugzilla API token", async () => { 99 | await loadPage({ 100 | url: "/addon/content/options/options.html", 101 | setup: setupWithServices, 102 | test: async(content, document) => { 103 | const NEW_KEY = "xyz54321"; 104 | let field = document.getElementById("bugzilla-apiKey"); 105 | field.value.should.equal("abc123"); 106 | 107 | // Now update the value 108 | changeFieldValue(field, NEW_KEY); 109 | browser.storage.local.set.withArgs({ services: undefined, }).returns( 110 | Promise.resolve() 111 | ); 112 | 113 | assert.ok(browser.storage.local.set.calledOnce); 114 | assert.ok(browser.storage.local.set.calledWith({ 115 | services: [{ 116 | id: 1, 117 | type: "bugzilla", 118 | settings: { 119 | apiKey: NEW_KEY, 120 | }, 121 | }, { 122 | id: 2, 123 | type: "github", 124 | settings: { 125 | username: "mikeconley", 126 | }, 127 | }, { 128 | id: 3, 129 | type: "phabricator", 130 | settings: { 131 | container: 0, 132 | inclReviewerGroups: true, 133 | }, 134 | },], 135 | })); 136 | }, 137 | }); 138 | }); 139 | 140 | it("should be able to update the needinfo state for Bugzilla", async () => { 141 | await loadPage({ 142 | url: "/addon/content/options/options.html", 143 | setup: setupWithServices, 144 | test: async(content, document) => { 145 | let field = document.getElementById("bugzilla-needinfos"); 146 | field.checked.should.equal(false); 147 | 148 | browser.storage.local.set.withArgs({ services: undefined, }).returns( 149 | Promise.resolve() 150 | ); 151 | 152 | // Now update the value 153 | field.click(); 154 | 155 | assert.ok(browser.storage.local.set.calledOnce); 156 | assert.ok(browser.storage.local.set.calledWith({ 157 | services: [{ 158 | id: 1, 159 | type: "bugzilla", 160 | settings: { 161 | apiKey: "abc123", 162 | needinfos: true, 163 | }, 164 | }, { 165 | id: 2, 166 | type: "github", 167 | settings: { 168 | username: "mikeconley", 169 | }, 170 | }, { 171 | id: 3, 172 | type: "phabricator", 173 | settings: { 174 | container: 0, 175 | inclReviewerGroups: true, 176 | }, 177 | },], 178 | })); 179 | }, 180 | }); 181 | }); 182 | 183 | it("should show and be able to update the GitHub username", async () => { 184 | await loadPage({ 185 | url: "/addon/content/options/options.html", 186 | setup: setupWithServices, 187 | test: async(content, document) => { 188 | const NEW_USERNAME = "hoobastank"; 189 | let field = document.getElementById("github-username"); 190 | field.value.should.equal("mikeconley"); 191 | 192 | // Now update the value 193 | changeFieldValue(field, NEW_USERNAME); 194 | browser.storage.local.set.withArgs({ services: undefined, }).returns( 195 | Promise.resolve() 196 | ); 197 | 198 | assert.ok(browser.storage.local.set.calledOnce); 199 | assert.ok(browser.storage.local.set.calledWith({ 200 | services: [{ 201 | id: 1, 202 | type: "bugzilla", 203 | settings: { 204 | apiKey: "abc123", 205 | }, 206 | }, { 207 | id: 2, 208 | type: "github", 209 | settings: { 210 | username: "hoobastank", 211 | }, 212 | }, { 213 | id: 3, 214 | type: "phabricator", 215 | settings: { 216 | container: 0, 217 | inclReviewerGroups: true, 218 | }, 219 | },], 220 | })); 221 | }, 222 | }); 223 | }); 224 | }); 225 | 226 | const WEEKENDS = [ 227 | "saturday", 228 | "sunday", 229 | ]; 230 | 231 | const WEEKDAYS = [ 232 | "monday", 233 | "tuesday", 234 | "wednesday", 235 | "thursday", 236 | "friday", 237 | ]; 238 | 239 | describe("Options page", function() { 240 | it("should be able to set working hours from default state", async () => { 241 | await loadPage({ 242 | url: "/addon/content/options/options.html", 243 | setup: setupBlank, 244 | test: async(content, document) => { 245 | // By default, the working hours fields should be disabled. 246 | let fieldset = document.getElementById("working-hours-fields"); 247 | assert.ok(fieldset.disabled); 248 | 249 | // Default workday is 9-5, in HH:MM. 250 | let startTime = document.getElementById("start-time").value; 251 | assert.equal(startTime, "09:00"); 252 | let endTime = document.getElementById("end-time").value; 253 | assert.equal(endTime, "17:00"); 254 | 255 | // Monday-Friday should be checked by default, weekends not checked. 256 | let boxes = fieldset.querySelectorAll("input[type='checkbox']"); 257 | assert.equal(boxes.length, WEEKDAYS.length + WEEKENDS.length); 258 | for (let box of boxes) { 259 | if (WEEKDAYS.includes(box.id)) { 260 | assert.ok(box.checked); 261 | } else if (WEEKENDS.includes(box.id)) { 262 | assert.ok(!box.checked); 263 | } else { 264 | assert.ok(false, "Did not expect a checkbox with id: " + box.id); 265 | } 266 | } 267 | 268 | let checkbox = document.getElementById("working-hours-checkbox"); 269 | checkbox.click(); 270 | assert.ok(!fieldset.hasAttribute("disabled")); 271 | 272 | assert.ok(browser.storage.local.set.calledOnce); 273 | assert.ok(browser.storage.local.set.calledWith({ 274 | workingHours: { 275 | enabled: true, 276 | days: WEEKDAYS, 277 | startTime: "09:00", 278 | endTime: "17:00", 279 | }, 280 | })); 281 | }, 282 | }); 283 | }); 284 | }); 285 | -------------------------------------------------------------------------------- /tests/services/phabricator/one-ready.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Must Review

  • No revisions are blocked on your review.

Ready to Review

Ready to Land

Drafts

  • You have no draft revisions.

Waiting on Review

Waiting on Authors

Waiting on Other Reviewers

-------------------------------------------------------------------------------- /tests/services/phabricator/phabricator.test.js: -------------------------------------------------------------------------------- 1 | describe("Phabricator", function() { 2 | const TEST_URL_PREFIX = "base/tests/services/phabricator"; 3 | 4 | it("should be able to load the simple case", async function() { 5 | let testingURL = [TEST_URL_PREFIX, "one-ready.html",].join("/"); 6 | let { ok, reviewTotal, } = 7 | await MyQOnly.phabricatorReviewRequests({ testingURL, }); 8 | assert.ok(ok); 9 | assert.equal(reviewTotal, 1); 10 | }); 11 | 12 | it("should be able to load the empty case", async function() { 13 | let testingURL = [TEST_URL_PREFIX, "empty.html",].join("/"); 14 | let { ok, reviewTotal, } = 15 | await MyQOnly.phabricatorReviewRequests({ testingURL, }); 16 | assert.ok(ok); 17 | assert.equal(reviewTotal, 0); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/test-utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | 3 | /** 4 | * Runs some optional setup script to prepare sinon-chrome with 5 | * some values, and then loads a url in an iframe in the main document 6 | * body, waits for it to load, and runs a test function. The test 7 | * function is passed the content window of the iframe. 8 | * 9 | * @params options (object) 10 | * 11 | * url (string): 12 | * The URL of the page to load. 13 | * 14 | * setup (async function(browser), optional): 15 | * A function that accepts a single argument, which is a 16 | * sinon-chrome WebExtension API mockery that can be prepared 17 | * with values. 18 | * 19 | * waitForInitted (bool, optional): 20 | * Defaults to true. Waits for a page to fire an "initted" event 21 | * on the document before running the test. 22 | * 23 | * test (async function(content)): 24 | * The test function that accepts two arguments: the content 25 | * window for the loaded iframe, and the content document. When 26 | * this function is called, the load event has already fired in 27 | * the document. If waitForInitted is true, the document has also 28 | * fired a custom "initted" event. 29 | * 30 | */ 31 | async function loadPage({ url, setup, waitForInitted = true, test, } = {}) { 32 | let iframe = document.createElement("iframe"); 33 | // Karma hosts these files at http://localhost/base/ + file path. 34 | // See http://karma-runner.github.io/3.0/config/files.html 35 | iframe.src = "base" + url; 36 | document.body.appendChild(iframe); 37 | let browser = chrome; 38 | 39 | // Reset sinon-chrome 40 | browser.flush(); 41 | if (setup) { 42 | await setup(browser); 43 | } 44 | 45 | iframe.contentWindow.browser = chrome; 46 | iframe.contentWindow.console = console; 47 | 48 | await new Promise(resolve => { 49 | let event = waitForInitted ? "initted" : "load"; 50 | iframe.contentWindow.addEventListener(event, resolve, { once: true, }); 51 | }); 52 | 53 | await test(iframe.contentWindow, iframe.contentDocument); 54 | 55 | iframe.remove(); 56 | } 57 | 58 | function changeFieldValue(field, value) { 59 | field.value = value; 60 | let win = field.ownerDocument.defaultView; 61 | field.dispatchEvent(new win.Event("change", { 62 | bubbles: true, 63 | })); 64 | } 65 | --------------------------------------------------------------------------------