├── .ackrc ├── .babelrc ├── .circleci └── config.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .githooks └── commit-msg │ └── check-for-changelog-lint ├── .github └── dependabot.yml ├── .gitignore ├── .mocharc.cjs ├── .npmignore ├── .npmrc ├── .nsprc ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── artifacts └── README.md ├── bin └── web-ext.js ├── commitlint.config.cjs ├── index.js ├── package-lock.json ├── package.json ├── scripts ├── .eslintrc ├── audit-deps.js ├── build.js ├── develop.js ├── github-pr-title-lint.js ├── lib │ ├── babel.js │ ├── config.js │ ├── eslint.js │ └── mocha.js ├── test-functional.js └── test.js ├── src ├── cmd │ ├── build.js │ ├── docs.js │ ├── dump-config.js │ ├── index.js │ ├── lint.js │ ├── run.js │ └── sign.js ├── config.js ├── errors.js ├── extension-runners │ ├── chromium.js │ ├── firefox-android.js │ ├── firefox-desktop.js │ └── index.js ├── firefox │ ├── index.js │ ├── package-identifiers.js │ ├── preferences.js │ ├── rdp-client.js │ └── remote.js ├── main.js ├── program.js ├── util │ ├── adb.js │ ├── artifacts.js │ ├── desktop-notifier.js │ ├── file-exists.js │ ├── file-filter.js │ ├── is-directory.js │ ├── logger.js │ ├── manifest.js │ ├── promisify.js │ ├── stdin.js │ ├── submit-addon.js │ ├── temp-dir.js │ └── updates.js └── watcher.js └── tests ├── .eslintrc ├── fixtures ├── .eslintrc ├── dashed-locale │ ├── _locales │ │ └── en_US │ │ │ └── messages.json │ └── manifest.json ├── minimal-localizable-web-ext │ ├── _locales │ │ └── en │ │ │ └── messages.json │ ├── background-script.js │ └── manifest.json ├── minimal-web-ext │ ├── .private-file1.txt │ ├── background-script.js │ ├── manifest.json │ └── node_modules │ │ ├── pkg1 │ │ └── file1.txt │ │ └── pkg2 │ │ └── file2.txt ├── minimal_extension-1.0.zip ├── slashed-name │ └── manifest.json └── webext-as-library │ ├── helpers.js │ ├── test-import.mjs │ └── test-require.js ├── functional ├── common.js ├── fake-amo-server.js ├── fake-firefox-binary.bat ├── fake-firefox-binary.js ├── test.cli.build.js ├── test.cli.dump-config.js ├── test.cli.lint.js ├── test.cli.nocommand.js ├── test.cli.run.js ├── test.cli.sign.js ├── test.lib.imports.js └── test.typo.run.js ├── setup.js └── unit ├── helpers.js ├── test-cmd ├── test.build.js ├── test.docs.js ├── test.lint.js ├── test.run.js └── test.sign.js ├── test-extension-runners ├── test.chromium.js ├── test.extension-runners.js ├── test.firefox-android.js └── test.firefox-desktop.js ├── test-firefox ├── test.firefox.js ├── test.preferences.js ├── test.rdp-client.js └── test.remote.js ├── test-util ├── test.adb.js ├── test.artifacts.js ├── test.desktop-notifier.js ├── test.file-exists.js ├── test.file-filter.js ├── test.is-directory.js ├── test.logger.js ├── test.manifest.js ├── test.promisify.js ├── test.submit-addon.js ├── test.temp-dir.js └── test.updates.js ├── test.config.js ├── test.errors.js ├── test.program.js ├── test.setup.js ├── test.watcher.js └── test.web-ext.js /.ackrc: -------------------------------------------------------------------------------- 1 | # This sets some defaults for ack (https://beyondgrep.com/) 2 | # that make searching the web-ext code a bit easier. 3 | 4 | --ignore-dir=artifacts 5 | --ignore-dir=dist 6 | --ignore-dir=node_modules 7 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", { 5 | "targets": {"node": "14"}, 6 | // Leave import/export statements unchanged in the babel transpiling output. 7 | "modules": false 8 | } 9 | ] 10 | ], 11 | "plugins": [ 12 | ["transform-inline-environment-variables", { 13 | "include": [ 14 | "WEBEXT_BUILD_ENV" 15 | ] 16 | }] 17 | ], 18 | "env": { 19 | "test": { 20 | "plugins": [ "istanbul" ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.js] 2 | indent_style = space 3 | indent_size = 2 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | !scripts/lib 3 | coverage/ 4 | artifacts/ 5 | commitlint.config.js 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@babel/eslint-parser", 3 | "parserOptions": { 4 | "ecmaVersion": 6, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "arrowFunctions": true, 8 | "blockBindings": true, 9 | "classes": true, 10 | "destructuring": true, 11 | "defaultParams": true, 12 | "modules": true, 13 | "restParams": true, 14 | "spread": true 15 | }, 16 | "babelConfig": { 17 | "configFile": "./.babelrc" 18 | }, 19 | "requireConfigFile": false 20 | }, 21 | "plugins": [ 22 | "async-await", 23 | "import" 24 | ], 25 | "env": { 26 | "node": true, 27 | "es6": true 28 | }, 29 | "extends": [ 30 | "eslint:recommended", 31 | ], 32 | "globals": { 33 | "exports": false, 34 | "module": false, 35 | "require": false 36 | }, 37 | "rules": { 38 | "arrow-parens": 2, 39 | "arrow-spacing": 2, 40 | "block-scoped-var": 0, 41 | "brace-style": [2, "1tbs", {"allowSingleLine": false}], 42 | "camelcase": 0, 43 | "comma-dangle": [2, "always-multiline"], 44 | "comma-spacing": 2, 45 | "comma-style": [2, "last"], 46 | "curly": [2, "all"], 47 | "dot-notation": [2, {"allowKeywords": true}], 48 | "eqeqeq": [2, "allow-null"], 49 | "guard-for-in": 0, 50 | "key-spacing": 2, 51 | "keyword-spacing": 2, 52 | "new-cap": [2, {"capIsNewExceptions": ["Deferred"]}], 53 | "no-bitwise": 2, 54 | "no-caller": 2, 55 | "no-cond-assign": [2, "except-parens"], 56 | "no-console": 2, 57 | "no-debugger": 2, 58 | "no-empty": 2, 59 | "no-eval": 2, 60 | "no-extend-native": 2, 61 | "no-extra-parens": 0, 62 | "no-extra-semi": 2, 63 | "no-implicit-coercion": [2, { 64 | "boolean": true, 65 | "number": true, 66 | "string": true, 67 | }], 68 | "no-irregular-whitespace": 2, 69 | "no-iterator": 2, 70 | "no-loop-func": 0, 71 | "no-mixed-spaces-and-tabs": 2, 72 | "no-multi-str": 2, 73 | "no-multi-spaces": 2, 74 | "no-multiple-empty-lines": [2, {"max": 2}], 75 | "no-new": 2, 76 | "no-plusplus": 0, 77 | "no-proto": 2, 78 | "no-redeclare": 0, 79 | "no-shadow": [2, {"builtinGlobals": true}], 80 | "no-shadow-restricted-names": 2, 81 | "no-script-url": 2, 82 | "no-sequences": 2, 83 | "no-template-curly-in-string": 2, 84 | "no-trailing-spaces": [2, {"skipBlankLines": false}], 85 | "no-undef": 2, 86 | "no-underscore-dangle": 0, 87 | "no-unneeded-ternary": 2, 88 | "no-unused-vars": 2, 89 | "no-with": 2, 90 | "object-property-newline": [2, { 91 | "allowMultiplePropertiesPerLine": true 92 | }], 93 | "object-shorthand": 2, 94 | "one-var": [2, "never"], 95 | "prefer-const": 2, 96 | "prefer-template": 2, 97 | "quotes": [2, "single", "avoid-escape"], 98 | "require-yield": 2, 99 | "semi": [2, "always"], 100 | "space-before-blocks": [2, "always"], 101 | "space-infix-ops": 2, 102 | "strict": [2, "never"], 103 | "valid-typeof": 2, 104 | "wrap-iife": [2, "inside"], 105 | 106 | "async-await/space-after-async": 2, 107 | "async-await/space-after-await": 2, 108 | 109 | // This makes sure imported modules exist. 110 | "import/no-unresolved": 2, 111 | // This makes sure imported names exist. 112 | "import/named": 2, 113 | // This will catch accidental default imports when no default is defined. 114 | "import/default": 2, 115 | // This makes sure `*' imports are dereferenced to real exports. 116 | "import/namespace": 2, 117 | // This catches any export mistakes. 118 | "import/export": 2, 119 | // This catches default names that conflict with actual exported names. 120 | // For example, this was probably a typo: 121 | // import foo from 'bar'; 122 | // that should be corrected as: 123 | // import { foo } from 'bar'; 124 | "import/no-named-as-default": 2, 125 | // This catches possible typos like trying to access a real export on a 126 | // default import. 127 | "import/no-named-as-default-member": 2, 128 | // This prevents exporting a mutable variable. 129 | "import/no-mutable-exports": 2, 130 | // This makes sure package.json defines dev vs. prod dependencies correctly. 131 | "import/no-extraneous-dependencies": [2, { 132 | // The following are not allowed to be imported. See .eslintrc in other 133 | // directories (like ./test) for where this gets overidden. 134 | "devDependencies":["Gruntfile.js", "webpack.*.js", "tasks/*.js"], 135 | "optionalDependencies": false, "peerDependencies": false 136 | }], 137 | // This ensures imports are at the top of the file. 138 | "import/imports-first": 2, 139 | // This catches duplicate exports. 140 | "import/no-duplicates": 2, 141 | // This ensures import statements never provide a file extension in the path. 142 | // NOTE: disabled due to https://github.com/import-js/eslint-plugin-import/issues/2104 143 | "import/extensions": [0, "never"], 144 | // This ensures imports are organized by type and that groups are separated 145 | // by a new line. 146 | "import/order": [2, { 147 | "groups": [ 148 | "builtin", "external", "internal", ["parent", "sibling"], "index" 149 | ], 150 | "newlines-between": "always" 151 | }], 152 | // This ensures a new line after all import statements. 153 | "import/newline-after-import": 2, 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /.githooks/commit-msg/check-for-changelog-lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This git-hook is not used anymore, and so we warn the user that it can be removed. 3 | 4 | echo "--------------------------------------------------------------------" 5 | echo "This git-hook is not used anymore and you can now remove it from" 6 | echo "your local git hooks dir (.git/hooks/commit-msg)" 7 | echo "--------------------------------------------------------------------" 8 | echo 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 99 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | .DS_Store 3 | 4 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 5 | .grunt 6 | .cache 7 | 8 | # Build artifacts. 9 | .nyc_output 10 | npm-debug.log 11 | node_modules 12 | dist/* 13 | artifacts/* 14 | coverage/* 15 | lib/ 16 | -------------------------------------------------------------------------------- /.mocharc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // On TravisCI, sometimes the tests that require I/O need extra time 3 | // (even more when running in a travis windows worker). 4 | timeout: process.env.TRAVIS_OS_NAME === 'windows' ? 30000 : 10000, 5 | diff: true, 6 | package: './package.json', 7 | reporter: 'spec', 8 | require: [ 9 | '@babel/register', 10 | './tests/setup.js', 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # The files to be included in the npm package are listed in the package.json file, 2 | # in the `files` property (See https://docs.npmjs.com/files/package.json#files). 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-prefix='' 2 | -------------------------------------------------------------------------------- /.nsprc: -------------------------------------------------------------------------------- 1 | { 2 | "exceptions": [] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # exclude everything by default 2 | *.* 3 | 4 | # exclude these files 5 | package-lock.json 6 | LICENSE 7 | 8 | # exclude these directories 9 | /artifacts/ 10 | /coverage/ 11 | /lib/ 12 | /node_modules/ 13 | /tests/fixtures/ 14 | 15 | # allow files we want to process 16 | !*.js 17 | !*.md 18 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "singleQuote": true, 4 | "proseWrap": "never", 5 | "overrides": [ 6 | { 7 | "files": "scripts/*", 8 | "options": { 9 | "parser": "babel" 10 | } 11 | }, 12 | { 13 | "files": "*.md", 14 | "options": { 15 | "proseWrap": "preserve" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | 9 | For more information on how to report violations of the Community Participation Guidelines, 10 | please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 11 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Is this a feature request or a bug? 2 | 3 | ### What is the current behavior? 4 | 5 | 12 | 13 | ### What is the expected or desired behavior? 14 | 15 | ### Version information (for bug reports) 16 | 17 | - **Firefox version**: 18 | - **Your OS and version**: 19 | - Paste the output of these commands: 20 | 21 | ``` 22 | node --version && npm --version && web-ext --version 23 | ``` 24 | -------------------------------------------------------------------------------- /artifacts/README.md: -------------------------------------------------------------------------------- 1 | This is a directory of ephemeral artifacts that may be written during development, such as log files. -------------------------------------------------------------------------------- /bin/web-ext.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import path from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | import webExt from '../lib/main.js'; 7 | 8 | const absolutePackageDir = path.join( 9 | path.dirname(fileURLToPath(import.meta.url)), 10 | '..', 11 | ); 12 | 13 | await webExt.main(absolutePackageDir); 14 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "body-leading-blank": [0, "never"], 5 | "footer-leading-blank": [0, "never"], 6 | "header-max-length": [1, "always", 72], 7 | "subject-case": [0, "never"], 8 | "subject-full-stop": [0, "never"], 9 | "body-max-line-length": [0, "never"] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // This file is loaded when developers import 'web-ext' in their own code. 2 | 3 | // NOTE: disabled eslint rules: 4 | // - import/no-unresolved: in the CI jobs, the `lib/main.js` file has likely not been built yet. It's fine to 5 | // disable this rule because we have automated tests (which would catch an unresolved import issue 6 | // here). 7 | // 8 | // eslint-disable-next-line import/no-unresolved 9 | import webext from './lib/main.js'; 10 | 11 | export default webext; 12 | export const { cmd, main } = webext; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-ext", 3 | "version": "8.7.1", 4 | "description": "A command line tool to help build, run, and test web extensions", 5 | "type": "module", 6 | "main": "index.js", 7 | "exports": { 8 | ".": "./index.js", 9 | "./util/adb": "./lib/util/adb.js", 10 | "./util/logger": "./lib/util/logger.js", 11 | "./util/submit-addon": "./lib/util/submit-addon.js" 12 | }, 13 | "files": [ 14 | "index.js", 15 | "lib/**" 16 | ], 17 | "engines": { 18 | "node": ">=18.0.0", 19 | "npm": ">=8.0.0" 20 | }, 21 | "engine-strict": true, 22 | "bin": { 23 | "web-ext": "bin/web-ext.js" 24 | }, 25 | "scripts": { 26 | "build": "node scripts/build", 27 | "start": "node scripts/develop", 28 | "test": "node scripts/test", 29 | "test-coverage": "node scripts/test --coverage", 30 | "test-functional": "node scripts/test-functional", 31 | "audit-deps": "node ./scripts/audit-deps", 32 | "changelog": "npx conventional-changelog-cli -p angular -u", 33 | "changelog-lint": "commitlint --from master", 34 | "changelog-lint-from-stdin": "commitlint", 35 | "github-pr-title-lint": "node ./scripts/github-pr-title-lint", 36 | "gen-contributing-toc": "npx doctoc CONTRIBUTING.md", 37 | "prettier": "prettier --write '**'", 38 | "prettier-ci": "prettier --list-different '**' || (echo '\n\nThis failure means you did not run `npm run prettier-dev` before committing\n\n' && exit 1)", 39 | "prettier-dev": "pretty-quick --branch master" 40 | }, 41 | "homepage": "https://github.com/mozilla/web-ext", 42 | "repository": { 43 | "type": "git", 44 | "url": "git://github.com/mozilla/web-ext.git" 45 | }, 46 | "bugs": { 47 | "url": "http://github.com/mozilla/web-ext/issues" 48 | }, 49 | "keywords": [ 50 | "web", 51 | "extensions", 52 | "web extensions", 53 | "browser extensions", 54 | "firefox", 55 | "mozilla", 56 | "add-ons", 57 | "google", 58 | "chrome", 59 | "opera" 60 | ], 61 | "dependencies": { 62 | "@babel/runtime": "7.27.4", 63 | "@devicefarmer/adbkit": "3.3.8", 64 | "addons-linter": "7.13.0", 65 | "camelcase": "8.0.0", 66 | "chrome-launcher": "1.2.0", 67 | "debounce": "1.2.1", 68 | "decamelize": "6.0.0", 69 | "es6-error": "4.1.1", 70 | "firefox-profile": "4.7.0", 71 | "fx-runner": "1.4.0", 72 | "https-proxy-agent": "^7.0.0", 73 | "jose": "5.9.6", 74 | "jszip": "3.10.1", 75 | "multimatch": "6.0.0", 76 | "node-notifier": "10.0.1", 77 | "open": "10.1.2", 78 | "parse-json": "7.1.1", 79 | "pino": "9.7.0", 80 | "promise-toolbox": "0.21.0", 81 | "source-map-support": "0.5.21", 82 | "strip-bom": "5.0.0", 83 | "strip-json-comments": "5.0.2", 84 | "tmp": "0.2.3", 85 | "update-notifier": "7.3.1", 86 | "watchpack": "2.4.4", 87 | "ws": "8.18.2", 88 | "yargs": "17.7.2", 89 | "zip-dir": "2.0.0" 90 | }, 91 | "devDependencies": { 92 | "@babel/cli": "7.27.2", 93 | "@babel/core": "7.27.4", 94 | "@babel/eslint-parser": "7.27.1", 95 | "@babel/preset-env": "7.27.2", 96 | "@babel/register": "7.27.1", 97 | "@commitlint/cli": "19.8.1", 98 | "@commitlint/config-conventional": "19.8.1", 99 | "babel-plugin-istanbul": "7.0.0", 100 | "babel-plugin-transform-inline-environment-variables": "0.4.4", 101 | "chai": "5.2.0", 102 | "chai-as-promised": "8.0.1", 103 | "copy-dir": "1.3.0", 104 | "crc-32": "1.2.2", 105 | "cross-env": "7.0.3", 106 | "deepcopy": "2.1.0", 107 | "eslint": "8.57.0", 108 | "eslint-plugin-async-await": "0.0.0", 109 | "eslint-plugin-import": "2.31.0", 110 | "fs-extra": "11.3.0", 111 | "git-rev-sync": "3.0.2", 112 | "html-entities": "2.6.0", 113 | "mocha": "11.5.0", 114 | "nyc": "17.1.0", 115 | "prettier": "3.5.3", 116 | "pretty-quick": "4.2.2", 117 | "prettyjson": "1.2.5", 118 | "shelljs": "0.8.5", 119 | "sinon": "20.0.0", 120 | "testdouble": "3.20.2", 121 | "yauzl": "2.10.0" 122 | }, 123 | "author": "Kumar McMillan", 124 | "license": "MPL-2.0", 125 | "nyc": { 126 | "include": "src/**/*.js", 127 | "reporter": [ 128 | "lcov", 129 | "text" 130 | ] 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /scripts/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": 0, 4 | "import/extensions": 0, 5 | "import/no-extraneous-dependencies": 0, 6 | "max-len": 0, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /scripts/audit-deps.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // This nodejs script loads the .nsprc's "exceptions" list (as `nsp check` used to support) and 4 | // and then filters the output of `npm audit --json` to check if any of the security advisories 5 | // detected should be a blocking issue and force the CI job to fail. 6 | // 7 | // We can remove this script if/once npm audit will support this feature natively 8 | // (See https://github.com/npm/npm/issues/20565). 9 | 10 | import shell from 'shelljs'; 11 | import stripJsonComments from 'strip-json-comments'; 12 | 13 | const npmVersion = parseInt( 14 | shell.exec('npm --version', { silent: true }).stdout.split('.')[0], 15 | 10, 16 | ); 17 | const npmCmd = npmVersion >= 6 ? 'npm' : 'npx npm@latest'; 18 | 19 | if (npmCmd.startsWith('npx') && !shell.which('npx')) { 20 | shell.echo('Sorry, this script requires npm >= 6 or npx installed globally'); 21 | shell.exit(1); 22 | } 23 | 24 | if (!shell.test('-f', 'package-lock.json')) { 25 | console.log('audit-deps is generating the missing package-lock.json file'); 26 | shell.exec(`${npmCmd} i --package-lock-only`); 27 | } 28 | 29 | // Collect audit results and split them into blocking and ignored issues. 30 | function getNpmAuditJSON() { 31 | const res = shell.exec(`${npmCmd} audit --json`, { silent: true }); 32 | if (res.code !== 0) { 33 | try { 34 | return JSON.parse(res.stdout); 35 | } catch (err) { 36 | console.error('Error parsing npm audit output:', res.stdout); 37 | throw err; 38 | } 39 | } 40 | // npm audit didn't found any security advisories. 41 | return null; 42 | } 43 | 44 | const blockingIssues = []; 45 | const ignoredIssues = []; 46 | let auditReport = getNpmAuditJSON(); 47 | 48 | if (auditReport) { 49 | const cmdres = shell.cat('.nsprc'); 50 | const { exceptions } = JSON.parse(stripJsonComments(cmdres.stdout)); 51 | 52 | if (auditReport.error) { 53 | if (auditReport.error.code === 'ENETUNREACH') { 54 | console.log( 55 | 'npm was not able to reach the api endpoint:', 56 | auditReport.error.summary, 57 | ); 58 | console.log('Retrying...'); 59 | auditReport = getNpmAuditJSON(); 60 | } 61 | 62 | // If the error code is not ENETUNREACH or it fails again after a single retry 63 | // just log the audit error and exit with error code 2. 64 | if (auditReport.error) { 65 | console.error('npm audit error:', auditReport.error); 66 | process.exit(2); 67 | } 68 | } 69 | 70 | if (auditReport.auditReportVersion > 2) { 71 | // Throw a more clear error when a new format that this script does not expect 72 | // has been introduced. 73 | console.error( 74 | 'ERROR: npm audit JSON is using a new format not yet supported.', 75 | '\nPlease file a bug in the github repository and attach the following JSON data sample to it:', 76 | `\n\n${JSON.stringify(auditReport, null, 2)}`, 77 | ); 78 | } else if (auditReport.auditReportVersion === 2) { 79 | // New npm audit json format introduced in npm v8. 80 | for (const vulnerablePackage of Object.keys(auditReport.vulnerabilities)) { 81 | const item = auditReport.vulnerabilities[vulnerablePackage]; 82 | // `item.via` can be either objects or (string) names of vulnerable 83 | // packages in the audit json report. We need to normalize the data so 84 | // that we always deal with a list of objects. 85 | item.via = item.via.reduce((acc, via) => { 86 | const addAdvisoryDetails = (entries, newEntry) => { 87 | if (entries.some((entry) => entry.url === newEntry.url)) { 88 | // The advisory url is already listed, no need to add a new entry. 89 | return; 90 | } 91 | entries.push(newEntry); 92 | }; 93 | 94 | if (typeof via === 'string') { 95 | // Resolve the actual security advisory details recursively. 96 | const recursivelyResolveVia = (currVia) => { 97 | const resolvedVia = auditReport.vulnerabilities[currVia].via; 98 | for (const viaEntry of resolvedVia) { 99 | if (typeof viaEntry === 'string') { 100 | recursivelyResolveVia(viaEntry); 101 | } else { 102 | addAdvisoryDetails(acc, viaEntry); 103 | } 104 | } 105 | }; 106 | 107 | recursivelyResolveVia(via); 108 | } else { 109 | addAdvisoryDetails(acc, via); 110 | } 111 | 112 | return acc; 113 | }, []); 114 | 115 | if (item.via.every((via) => exceptions.includes(via.url))) { 116 | ignoredIssues.push(item); 117 | continue; 118 | } 119 | blockingIssues.push(item); 120 | } 121 | } else { 122 | // Old npm audit json format for npm versions < npm v8 123 | for (const advId of Object.keys(auditReport.advisories)) { 124 | const adv = auditReport.advisories[advId]; 125 | 126 | if (exceptions.includes(adv.url)) { 127 | ignoredIssues.push(adv); 128 | continue; 129 | } 130 | blockingIssues.push(adv); 131 | } 132 | } 133 | } 134 | 135 | // Reporting. 136 | 137 | function formatAdvisoryV1(adv) { 138 | function formatFinding(desc) { 139 | return `${desc.version}, paths: ${desc.paths.join(', ')}`; 140 | } 141 | const findings = adv.findings 142 | .map(formatFinding) 143 | .map((msg) => ` ${msg}`) 144 | .join('\n'); 145 | return `${adv.module_name} (${adv.url}):\n${findings}`; 146 | } 147 | 148 | function formatAdvisoryV2(adv) { 149 | function formatVia(via) { 150 | return `${via.url}\n ${via.dependency} ${via.range}\n ${via.title}`; 151 | } 152 | const entryVia = adv.via 153 | .map(formatVia) 154 | .map((msg) => ` ${msg}`) 155 | .join('\n'); 156 | const fixAvailable = Boolean(adv.fixAvailable); 157 | const entryDetails = `isDirect: ${adv.isDirect}, severity: ${adv.severity}, fixAvailable: ${fixAvailable}`; 158 | return `${adv.name} (${entryDetails}):\n${entryVia}`; 159 | } 160 | 161 | function formatAdvisory(adv) { 162 | return auditReport.auditReportVersion === 2 163 | ? formatAdvisoryV2(adv) 164 | : formatAdvisoryV1(adv); 165 | } 166 | 167 | if (ignoredIssues.length > 0) { 168 | console.log( 169 | '\n== audit-deps: ignored security issues (based on .nsprc exceptions)\n', 170 | ); 171 | 172 | for (const adv of ignoredIssues) { 173 | console.log(formatAdvisory(adv)); 174 | } 175 | } 176 | 177 | if (blockingIssues.length > 0) { 178 | console.log('\n== audit-deps: blocking security issues\n'); 179 | 180 | for (const adv of blockingIssues) { 181 | console.log(formatAdvisory(adv)); 182 | } 183 | 184 | // Exit with error if blocking security issues has been found. 185 | process.exit(1); 186 | } 187 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import shell from 'shelljs'; 4 | 5 | import config from './lib/config.js'; 6 | import babel from './lib/babel.js'; 7 | 8 | shell.set('-e'); 9 | 10 | shell.echo('Clean dist files...'); 11 | shell.rm('-rf', config.clean); 12 | 13 | shell.echo('Running babel-cli...'); 14 | process.env.WEBEXT_BUILD_ENV = process.env.NODE_ENV || 'development'; 15 | babel(); 16 | shell.echo('babel build completed.'); 17 | -------------------------------------------------------------------------------- /scripts/develop.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import path from 'path'; 4 | 5 | import Watchpack from 'watchpack'; 6 | import notifier from 'node-notifier'; 7 | 8 | import config from './lib/config.js'; 9 | import eslint from './lib/eslint.js'; 10 | import { mochaUnit, mochaFunctional } from './lib/mocha.js'; 11 | import babel from './lib/babel.js'; 12 | 13 | const COVERAGE = 14 | process.argv.includes('--coverage') || process.env.COVERAGE === 'y'; 15 | 16 | const wp = new Watchpack(); 17 | wp.watch(config.watch.files, config.watch.dirs); 18 | 19 | function notify(message) { 20 | notifier.notify({ title: 'web-ext develop: ', message }); 21 | } 22 | 23 | let changed = new Set(); 24 | 25 | async function runTasks(changes) { 26 | const changesDetected = `\nChanges detected. ${changes 27 | .slice(0, 5) 28 | .join(' ')}...`; 29 | console.log(changesDetected); 30 | notify(changesDetected); 31 | 32 | console.log('\nRunning eslint checks'); 33 | if (!eslint()) { 34 | notify('eslint errors'); 35 | return; 36 | } 37 | 38 | console.log('\nRunning unit tests'); 39 | if (!mochaUnit({}, COVERAGE)) { 40 | notify('mocha unit tests errors'); 41 | return; 42 | } 43 | 44 | console.log('\nBuilding web-ext'); 45 | if (!babel()) { 46 | notify('babel build errors'); 47 | return; 48 | } 49 | 50 | console.log('\nRunning functional tests'); 51 | if (!mochaFunctional()) { 52 | notify('mocha functional tests errors'); 53 | return; 54 | } 55 | } 56 | 57 | wp.on('change', (changedFile, mtime) => { 58 | if (mtime === null) { 59 | changed.delete(changedFile); 60 | } else { 61 | changed.add(changedFile); 62 | } 63 | }); 64 | 65 | wp.on('aggregated', async () => { 66 | // Filter out files that start with a dot from detected changes 67 | // (as they are hidden files or temp files created by an editor). 68 | const changes = Array.from(changed).filter((filePath) => { 69 | return !path.basename(filePath).startsWith('.'); 70 | }); 71 | changed = new Set(); 72 | 73 | if (changes.length === 0) { 74 | return; 75 | } 76 | 77 | await runTasks(changes); 78 | 79 | console.log('\nDone. Waiting for changes...'); 80 | }); 81 | -------------------------------------------------------------------------------- /scripts/github-pr-title-lint.js: -------------------------------------------------------------------------------- 1 | /* eslint max-len: 0 */ 2 | 3 | import https from 'https'; 4 | import { execSync } from 'child_process'; 5 | 6 | import { decode } from 'html-entities'; 7 | import shelljs from 'shelljs'; 8 | 9 | const { 10 | // Set by circleci on pull request jobs, and it contains the entire url, e.g.: 11 | // CIRCLE_PULL_REQUEST='https://github.com/mozilla/web-ext/pull/89' 12 | CIRCLE_PULL_REQUEST, 13 | 14 | // To be set to test changes to the PR title linting by forcing it temporarily 15 | // (the PR #89 linked above is one that is supposed to fail the linting, 16 | // PR #79 title should instead pass the linting checks), e.g. the following 17 | // command can be used to test an expected linting failure: 18 | // 19 | // CIRCLE_PULL_REQUEST='https://github.com/mozilla/web-ext/pull/89' \ 20 | // TEST_FORCE_PR_LINT=1 npm run github-pr-title-lint 21 | TEST_FORCE_PR_LINT, 22 | VERBOSE, 23 | } = process.env; 24 | 25 | const DONT_PANIC_MESSAGE = ` 26 | Don't panic! If the CI job is failing here, please take a look at 27 | 28 | - https://github.com/mozilla/web-ext/blob/master/CONTRIBUTING.md#writing-commit-messages 29 | 30 | and feel free to ask for help from one of the maintainers in a comment; we are here to help ;-) 31 | `; 32 | 33 | function findMergeBase() { 34 | const res = shelljs.exec('git merge-base HEAD origin/master', { 35 | silent: true, 36 | }); 37 | 38 | if (res.code !== 0) { 39 | throw new Error(`findMergeBase Error: ${res.stderr}`); 40 | } 41 | 42 | const baseCommit = res.stdout.trim(); 43 | if (VERBOSE === 'true') { 44 | console.log('DEBUG findMergeBase:', baseCommit); 45 | } 46 | 47 | return baseCommit; 48 | } 49 | 50 | function getGitBranchCommits() { 51 | const baseCommit = findMergeBase(); 52 | const gitCommand = `git rev-list --no-merges HEAD ^${baseCommit}`; 53 | 54 | const res = shelljs.exec(gitCommand, { silent: true }); 55 | 56 | if (res.code !== 0) { 57 | throw new Error(`getGitBranchCommits Error: ${res.stderr}`); 58 | } 59 | 60 | const commits = res.stdout.trim().split('\n'); 61 | if (VERBOSE === 'true') { 62 | console.log('DEBUG getGitBranchCommits:', commits); 63 | } 64 | 65 | return commits; 66 | } 67 | 68 | function getGitCommitMessage(commitSha1) { 69 | const res = shelljs.exec(`git show -s --format=%B ${commitSha1}`, { 70 | silent: true, 71 | }); 72 | 73 | if (res.code !== 0) { 74 | throw new Error(`getGitCommitMessage Error: ${res.stderr}`); 75 | } 76 | 77 | const commitMessage = res.stdout.trim(); 78 | if (VERBOSE === 'true') { 79 | console.log(`DEBUG getGitCommitMessage: "${commitMessage}"`); 80 | } 81 | 82 | return commitMessage; 83 | } 84 | 85 | function getPullRequestTitle() { 86 | return new Promise(function (resolve, reject) { 87 | const pullRequestURL = CIRCLE_PULL_REQUEST; 88 | const pullRequestNumber = pullRequestURL.split('/').pop(); 89 | 90 | if (!/^\d+$/.test(pullRequestNumber)) { 91 | reject( 92 | new Error(`Unable to parse pull request number from ${pullRequestURL}`), 93 | ); 94 | return; 95 | } 96 | 97 | console.log(`Retrieving the pull request title from ${pullRequestURL}\n`); 98 | 99 | var req = https.get( 100 | pullRequestURL, 101 | { 102 | headers: { 103 | 'User-Agent': 'GitHub... your API can be very annoying ;-)', 104 | }, 105 | }, 106 | function (response) { 107 | if (response.statusCode < 200 || response.statusCode > 299) { 108 | reject( 109 | new Error( 110 | `getPullRequestTitle got an unexpected statusCode: ${response.statusCode}`, 111 | ), 112 | ); 113 | return; 114 | } 115 | 116 | response.setEncoding('utf8'); 117 | 118 | var body = ''; 119 | response.on('data', function (data) { 120 | try { 121 | body += data; 122 | 123 | if (VERBOSE === 'true') { 124 | console.log('DEBUG getPullRequestTitle got data:', String(data)); 125 | } 126 | 127 | // Once we get the closing title tag, we can read the pull request title and 128 | // close the http request. 129 | if (body.includes('')) { 130 | response.removeAllListeners('data'); 131 | response.emit('end'); 132 | 133 | var titleStart = body.indexOf(''); 134 | var titleEnd = body.indexOf(''); 135 | 136 | // NOTE: page slice is going to be something like: 137 | // " PR title by author · Pull Request #NUM · mozilla/web-ext · GitHub" 138 | var pageTitleParts = body 139 | .slice(titleStart, titleEnd) 140 | .replace('<title>', '') 141 | .split(' · '); 142 | 143 | // Check that we have really got the title of a real pull request. 144 | var expectedPart1 = `Pull Request #${pullRequestNumber}`; 145 | 146 | if (pageTitleParts[1] === expectedPart1) { 147 | // Remove the "by author" part. 148 | var prTitleEnd = pageTitleParts[0].lastIndexOf(' by '); 149 | resolve(pageTitleParts[0].slice(0, prTitleEnd)); 150 | } else { 151 | if (VERBOSE === 'true') { 152 | console.log('DEBUG getPullRequestTitle response:', body); 153 | } 154 | 155 | reject(new Error('Unable to retrieve the pull request title')); 156 | } 157 | 158 | req.abort(); 159 | } 160 | } catch (err) { 161 | reject(err); 162 | req.abort(); 163 | } 164 | }); 165 | response.on('error', function (err) { 166 | console.error('Failed during pull request title download: ', err); 167 | reject(err); 168 | }); 169 | }, 170 | ); 171 | }).then((message) => { 172 | return decode(message, { level: 'all' }); 173 | }); 174 | } 175 | 176 | function lintMessage(message) { 177 | if (!message) { 178 | throw new Error('Unable to lint an empty message.'); 179 | } 180 | 181 | try { 182 | return execSync('commitlint', { 183 | input: message, 184 | windowsHide: true, 185 | encoding: 'utf-8', 186 | }).trim(); 187 | } catch (e) { 188 | // execSync failure or timeouts. 189 | if (e.error) { 190 | throw e.error; 191 | } 192 | 193 | // commitlint non-zero exit 194 | if (e.status) { 195 | // stderr by default will be output to the parent process' stderr and so we just throw stdout (See 196 | // https://nodejs.org/api/child_process.html#child_process_child_process_execsync_command_options) 197 | throw e.stdout.trim(); 198 | } 199 | } 200 | } 201 | 202 | async function runChangelogLinting() { 203 | try { 204 | const commits = getGitBranchCommits(); 205 | let message; 206 | 207 | if (commits.length === 1 && !TEST_FORCE_PR_LINT) { 208 | console.log( 209 | 'There is only one commit in this pull request,', 210 | 'we are going to check the single commit message...', 211 | ); 212 | message = getGitCommitMessage(commits[0]); 213 | } else { 214 | console.log( 215 | 'There is more than one commit in this pull request,', 216 | 'we are going to check the pull request title...', 217 | ); 218 | 219 | message = await getPullRequestTitle(); 220 | } 221 | 222 | lintMessage(message); 223 | } catch (err) { 224 | var errMessage = `${err.stack || err}`.trim(); 225 | console.error( 226 | `Failures during changelog linting the pull request:\n\n${errMessage}`, 227 | ); 228 | console.log(DONT_PANIC_MESSAGE); 229 | process.exit(1); 230 | } 231 | 232 | console.log('Changelog linting completed successfully.'); 233 | } 234 | 235 | if (CIRCLE_PULL_REQUEST) { 236 | runChangelogLinting(); 237 | } else { 238 | console.log( 239 | 'This isn\'t a "GitHub Pull Request" CI job. Nothing to do here.', 240 | ); 241 | } 242 | -------------------------------------------------------------------------------- /scripts/lib/babel.js: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process'; 2 | 3 | import shell from 'shelljs'; 4 | import { expect } from 'chai'; 5 | 6 | export function isBuilt() { 7 | const isJS = (name) => name.endsWith('.js'); 8 | const srcModules = Array.from(shell.ls('-R', 'src/')).filter(isJS); 9 | const libModules = Array.from(shell.ls('-R', 'lib/')).filter(isJS); 10 | 11 | try { 12 | expect(libModules).to.deep.equal(srcModules); 13 | } catch (err) { 14 | if (err.name !== 'AssertionError') { 15 | throw err; 16 | } 17 | 18 | console.log( 19 | 'Missing build files in lib:', 20 | err.expected.reduce((result, filename) => { 21 | if (!err.actual.includes(filename)) { 22 | result += `\n- lib/${filename}`; 23 | } 24 | return result; 25 | }, ''), 26 | '\n' 27 | ); 28 | 29 | return false; 30 | } 31 | 32 | return true; 33 | } 34 | 35 | export default () => { 36 | const res = spawnSync( 37 | 'babel', 38 | ['--source-maps', 'true', 'src/', '-d', 'lib/'], 39 | { 40 | stdio: 'inherit', 41 | shell: true, 42 | } 43 | ); 44 | if (res.error) { 45 | console.error(res.error); 46 | return false; 47 | } 48 | 49 | return res.status === 0; 50 | }; 51 | -------------------------------------------------------------------------------- /scripts/lib/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | clean: ['lib/*'], 3 | watch: { 4 | files: ['package.json', 'webpack.config.js'], 5 | dirs: ['src', 'tests', 'scripts'], 6 | }, 7 | eslint: { 8 | files: [ 9 | '.', 10 | './index.js', 11 | './src/**/*.js', 12 | './tests/**/*.js', 13 | './scripts/**', 14 | ], 15 | }, 16 | mocha: { 17 | unit: [ 18 | './tests/unit/test.setup.js', 19 | './tests/unit/test.*.js', 20 | './tests/unit/**/test.*.js', 21 | ], 22 | functional: ['tests/functional/test.*.js'], 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /scripts/lib/eslint.js: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process'; 2 | 3 | import config from './config.js'; 4 | 5 | export default () => { 6 | const res = spawnSync('eslint', config.eslint.files, { 7 | stdio: 'inherit', 8 | shell: true, 9 | }); 10 | if (res.error) { 11 | console.error(res.error); 12 | return false; 13 | } 14 | 15 | return res.status === 0; 16 | }; 17 | -------------------------------------------------------------------------------- /scripts/lib/mocha.js: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process'; 2 | 3 | import shell from 'shelljs'; 4 | 5 | import config from './config.js'; 6 | 7 | // Get the explicit path (needed on CI windows workers). 8 | function which(...args) { 9 | return String(shell.which(...args)); 10 | } 11 | 12 | const runMocha = (args, execMochaOptions = {}, coverageEnabled) => { 13 | const mochaPath = which('mocha'); 14 | const binArgs = coverageEnabled ? [mochaPath, ...args] : args; 15 | const binPath = coverageEnabled ? which('nyc') : mochaPath; 16 | 17 | if (process.env.MOCHA_TIMEOUT) { 18 | const { MOCHA_TIMEOUT } = process.env; 19 | binArgs.push('--timeout', MOCHA_TIMEOUT); 20 | shell.echo(`\nSetting mocha timeout from env var: ${MOCHA_TIMEOUT}\n`); 21 | } 22 | 23 | // Pass testdouble node loader to support ESM module mocking and 24 | // transpiling on the fly the tests modules. 25 | binArgs.push('-n="loader=testdouble"'); 26 | 27 | const res = spawnSync(binPath, binArgs, { 28 | ...execMochaOptions, 29 | env: { 30 | ...process.env, 31 | // Make sure NODE_ENV is set to test (which also enable babel 32 | // install plugin for all modules transpiled on the fly). 33 | NODE_ENV: 'test', 34 | }, 35 | stdio: 'inherit', 36 | }); 37 | 38 | if (res.error) { 39 | console.error(res.error); 40 | return false; 41 | } 42 | 43 | return res.status === 0; 44 | }; 45 | 46 | export const mochaUnit = (execMochaOptions, coverageEnabled) => { 47 | return runMocha(config.mocha.unit, execMochaOptions, coverageEnabled); 48 | }; 49 | 50 | export const mochaFunctional = (execMochaOptions) => { 51 | return runMocha(config.mocha.functional, execMochaOptions); 52 | }; 53 | -------------------------------------------------------------------------------- /scripts/test-functional.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import path from 'path'; 4 | 5 | import shell from 'shelljs'; 6 | import tmp from 'tmp'; 7 | 8 | import { mochaFunctional } from './lib/mocha.js'; 9 | 10 | const pkg = JSON.parse(shell.cat('package.json')); 11 | 12 | shell.set('-e'); 13 | 14 | const packageFileName = `${pkg.name}-${pkg.version}.tgz`; 15 | const testProductionMode = process.env.TEST_PRODUCTION_MODE === '1'; 16 | const testLegacyBundling = process.env.TEST_LEGACY_BUNDLING === '1'; 17 | 18 | let execMochaOptions = {}; 19 | 20 | shell.exec( 21 | 'npm run build', 22 | testProductionMode 23 | ? { 24 | env: { 25 | ...process.env, 26 | NODE_ENV: 'production', 27 | }, 28 | } 29 | : {}, 30 | ); 31 | 32 | if (testProductionMode) { 33 | const srcDir = process.cwd(); 34 | const destDir = tmp.tmpNameSync(); 35 | const packageDir = tmp.tmpNameSync(); 36 | const npmInstallOptions = ['--production']; 37 | 38 | if (testLegacyBundling) { 39 | shell.echo('\nTest in "npm legacy bundling mode"'); 40 | npmInstallOptions.push('--legacy-bundling'); 41 | } 42 | 43 | execMochaOptions = { 44 | env: { 45 | ...process.env, 46 | TEST_WEB_EXT_BIN: path.join( 47 | destDir, 48 | 'node_modules', 49 | 'web-ext', 50 | 'bin', 51 | 'web-ext', 52 | ), 53 | }, 54 | }; 55 | 56 | shell.echo('\nPreparing web-ext production mode environment...\n'); 57 | shell.rm('-rf', destDir, packageDir); 58 | shell.mkdir('-p', destDir, packageDir); 59 | shell.pushd(packageDir); 60 | shell.exec(`npm pack ${srcDir}`); 61 | shell.popd(); 62 | shell.pushd(destDir); 63 | const pkgPath = path.join(packageDir, packageFileName); 64 | shell.exec(`npm install ${npmInstallOptions.join(' ')} ${pkgPath}`); 65 | shell.popd(); 66 | shell.echo('\nProduction mode environment successfully created.\n'); 67 | } 68 | 69 | let ok = mochaFunctional(execMochaOptions); 70 | 71 | // Try to re-run the functional tests once more if they fails on a CI windows worker (#1510). 72 | if (!ok && process.env.CI_RETRY_ONCE) { 73 | console.log( 74 | '*** Functional tests failure on a CI window worker, trying to re-run once more...', 75 | ); 76 | ok = mochaFunctional(execMochaOptions); 77 | } 78 | 79 | process.exit(ok ? 0 : 1); 80 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import eslint from './lib/eslint.js'; 4 | import { mochaUnit } from './lib/mocha.js'; 5 | import { isBuilt } from './lib/babel.js'; 6 | 7 | const COVERAGE = 8 | process.argv.includes('--coverage') || process.env.COVERAGE === 'y'; 9 | 10 | if (!isBuilt()) { 11 | console.error('web-ext transpiled sources missing. Run "npm run build".'); 12 | process.exit(1); 13 | } 14 | 15 | console.log('Running eslint...'); 16 | if (!eslint()) { 17 | process.exit(1); 18 | } 19 | 20 | console.log('Running mocha unit tests...', COVERAGE ? '(COVERAGE)' : ''); 21 | const ok = mochaUnit({}, COVERAGE); 22 | process.exit(ok ? 0 : 1); 23 | -------------------------------------------------------------------------------- /src/cmd/build.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { createWriteStream } from 'fs'; 3 | import fs from 'fs/promises'; 4 | 5 | import parseJSON from 'parse-json'; 6 | import stripBom from 'strip-bom'; 7 | import defaultFromEvent from 'promise-toolbox/fromEvent'; 8 | import zipDir from 'zip-dir'; 9 | 10 | import defaultSourceWatcher from '../watcher.js'; 11 | import getValidatedManifest, { getManifestId } from '../util/manifest.js'; 12 | import { prepareArtifactsDir } from '../util/artifacts.js'; 13 | import { createLogger } from '../util/logger.js'; 14 | import { UsageError, isErrorWithCode } from '../errors.js'; 15 | import { createFileFilter as defaultFileFilterCreator } from '../util/file-filter.js'; 16 | 17 | const log = createLogger(import.meta.url); 18 | const DEFAULT_FILENAME_TEMPLATE = '{name}-{version}.zip'; 19 | 20 | export function safeFileName(name) { 21 | return name.toLowerCase().replace(/[^a-z0-9.-]+/g, '_'); 22 | } 23 | 24 | // defaultPackageCreator types and implementation. 25 | 26 | // This defines the _locales/messages.json type. See: 27 | // https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Internationalization#Providing_localized_strings_in__locales 28 | 29 | export async function getDefaultLocalizedName({ messageFile, manifestData }) { 30 | let messageData; 31 | let messageContents; 32 | let extensionName = manifestData.name; 33 | 34 | try { 35 | messageContents = await fs.readFile(messageFile, { encoding: 'utf-8' }); 36 | } catch (error) { 37 | throw new UsageError( 38 | `Error reading messages.json file at ${messageFile}: ${error}`, 39 | ); 40 | } 41 | 42 | messageContents = stripBom(messageContents); 43 | 44 | const { default: stripJsonComments } = await import('strip-json-comments'); 45 | try { 46 | messageData = parseJSON(stripJsonComments(messageContents)); 47 | } catch (error) { 48 | throw new UsageError( 49 | `Error parsing messages.json file at ${messageFile}: ${error}`, 50 | ); 51 | } 52 | 53 | extensionName = manifestData.name.replace( 54 | /__MSG_([A-Za-z0-9@_]+?)__/g, 55 | (match, messageName) => { 56 | if (!(messageData[messageName] && messageData[messageName].message)) { 57 | const error = new UsageError( 58 | `The locale file ${messageFile} ` + `is missing key: ${messageName}`, 59 | ); 60 | throw error; 61 | } else { 62 | return messageData[messageName].message; 63 | } 64 | }, 65 | ); 66 | return Promise.resolve(extensionName); 67 | } 68 | 69 | // https://stackoverflow.com/a/22129960 70 | export function getStringPropertyValue(prop, obj) { 71 | const properties = prop.split('.'); 72 | const value = properties.reduce((prev, curr) => prev && prev[curr], obj); 73 | if (!['string', 'number'].includes(typeof value)) { 74 | throw new UsageError( 75 | `Manifest key "${prop}" is missing or has an invalid type: ${value}`, 76 | ); 77 | } 78 | const stringValue = `${value}`; 79 | if (!stringValue.length) { 80 | throw new UsageError(`Manifest key "${prop}" value is an empty string`); 81 | } 82 | return stringValue; 83 | } 84 | 85 | function getPackageNameFromTemplate(filenameTemplate, manifestData) { 86 | const packageName = filenameTemplate.replace( 87 | /{([A-Za-z0-9._]+?)}/g, 88 | (match, manifestProperty) => { 89 | return safeFileName( 90 | getStringPropertyValue(manifestProperty, manifestData), 91 | ); 92 | }, 93 | ); 94 | 95 | // Validate the resulting packageName string, after interpolating the manifest property 96 | // specified in the template string. 97 | const parsed = path.parse(packageName); 98 | if (parsed.dir) { 99 | throw new UsageError( 100 | `Invalid filename template "${filenameTemplate}". ` + 101 | `Filename "${packageName}" should not contain a path`, 102 | ); 103 | } 104 | if (!['.zip', '.xpi'].includes(parsed.ext)) { 105 | throw new UsageError( 106 | `Invalid filename template "${filenameTemplate}". ` + 107 | `Filename "${packageName}" should have a zip or xpi extension`, 108 | ); 109 | } 110 | 111 | return packageName; 112 | } 113 | 114 | export async function defaultPackageCreator( 115 | { 116 | manifestData, 117 | sourceDir, 118 | fileFilter, 119 | artifactsDir, 120 | overwriteDest, 121 | showReadyMessage, 122 | filename = DEFAULT_FILENAME_TEMPLATE, 123 | }, 124 | { fromEvent = defaultFromEvent } = {}, 125 | ) { 126 | let id; 127 | if (manifestData) { 128 | id = getManifestId(manifestData); 129 | log.debug(`Using manifest id=${id || '[not specified]'}`); 130 | } else { 131 | manifestData = await getValidatedManifest(sourceDir); 132 | } 133 | 134 | const buffer = await zipDir(sourceDir, { 135 | filter: (...args) => fileFilter.wantFile(...args), 136 | }); 137 | 138 | let filenameTemplate = filename; 139 | 140 | let { default_locale } = manifestData; 141 | if (default_locale) { 142 | default_locale = default_locale.replace(/-/g, '_'); 143 | const messageFile = path.join( 144 | sourceDir, 145 | '_locales', 146 | default_locale, 147 | 'messages.json', 148 | ); 149 | log.debug('Manifest declared default_locale, localizing extension name'); 150 | const extensionName = await getDefaultLocalizedName({ 151 | messageFile, 152 | manifestData, 153 | }); 154 | // allow for a localized `{name}`, without mutating `manifestData` 155 | filenameTemplate = filenameTemplate.replace(/{name}/g, extensionName); 156 | } 157 | 158 | const packageName = safeFileName( 159 | getPackageNameFromTemplate(filenameTemplate, manifestData), 160 | ); 161 | const extensionPath = path.join(artifactsDir, packageName); 162 | 163 | // Added 'wx' flags to avoid overwriting of existing package. 164 | const stream = createWriteStream(extensionPath, { flags: 'wx' }); 165 | 166 | stream.write(buffer, () => { 167 | stream.end(); 168 | }); 169 | 170 | try { 171 | await fromEvent(stream, 'close'); 172 | } catch (error) { 173 | if (!isErrorWithCode('EEXIST', error)) { 174 | throw error; 175 | } 176 | if (!overwriteDest) { 177 | throw new UsageError( 178 | `Extension exists at the destination path: ${extensionPath}\n` + 179 | 'Use --overwrite-dest to enable overwriting.', 180 | ); 181 | } 182 | log.info(`Destination exists, overwriting: ${extensionPath}`); 183 | const overwriteStream = createWriteStream(extensionPath); 184 | overwriteStream.write(buffer, () => { 185 | overwriteStream.end(); 186 | }); 187 | await fromEvent(overwriteStream, 'close'); 188 | } 189 | 190 | if (showReadyMessage) { 191 | log.info(`Your web extension is ready: ${extensionPath}`); 192 | } 193 | return { extensionPath }; 194 | } 195 | 196 | // Build command types and implementation. 197 | 198 | export default async function build( 199 | { 200 | sourceDir, 201 | artifactsDir, 202 | asNeeded = false, 203 | overwriteDest = false, 204 | ignoreFiles = [], 205 | filename = DEFAULT_FILENAME_TEMPLATE, 206 | }, 207 | { 208 | manifestData, 209 | createFileFilter = defaultFileFilterCreator, 210 | fileFilter = createFileFilter({ 211 | sourceDir, 212 | artifactsDir, 213 | ignoreFiles, 214 | }), 215 | onSourceChange = defaultSourceWatcher, 216 | packageCreator = defaultPackageCreator, 217 | showReadyMessage = true, 218 | } = {}, 219 | ) { 220 | const rebuildAsNeeded = asNeeded; // alias for `build --as-needed` 221 | log.info(`Building web extension from ${sourceDir}`); 222 | 223 | const createPackage = () => 224 | packageCreator({ 225 | manifestData, 226 | sourceDir, 227 | fileFilter, 228 | artifactsDir, 229 | overwriteDest, 230 | showReadyMessage, 231 | filename, 232 | }); 233 | 234 | await prepareArtifactsDir(artifactsDir); 235 | const result = await createPackage(); 236 | 237 | if (rebuildAsNeeded) { 238 | log.info('Rebuilding when files change...'); 239 | onSourceChange({ 240 | sourceDir, 241 | artifactsDir, 242 | onChange: () => { 243 | return createPackage().catch((error) => { 244 | log.error(error.stack); 245 | throw error; 246 | }); 247 | }, 248 | shouldWatchFile: (...args) => fileFilter.wantFile(...args), 249 | }); 250 | } 251 | 252 | return result; 253 | } 254 | -------------------------------------------------------------------------------- /src/cmd/docs.js: -------------------------------------------------------------------------------- 1 | import open from 'open'; 2 | 3 | import { createLogger } from '../util/logger.js'; 4 | 5 | const log = createLogger(import.meta.url); 6 | 7 | // eslint-disable-next-line max-len 8 | export const url = 9 | 'https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext/'; 10 | 11 | export default async function docs(params, { openUrl = open } = {}) { 12 | try { 13 | await openUrl(url); 14 | } catch (error) { 15 | log.debug(`Encountered an error while opening URL ${url}`, error); 16 | throw error; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/cmd/dump-config.js: -------------------------------------------------------------------------------- 1 | import { createLogger } from '../util/logger.js'; 2 | 3 | const log = createLogger(import.meta.url); 4 | 5 | export default async function config(configData) { 6 | log.info(JSON.stringify(configData, null, 2)); 7 | } 8 | -------------------------------------------------------------------------------- /src/cmd/index.js: -------------------------------------------------------------------------------- 1 | // This module exports entry points for all supported commands. For performance 2 | // reasons (faster start-up), the implementations are not statically imported 3 | // at the top of the file, but lazily loaded in the (exported) functions. 4 | // The latter would slow down start-up by several seconds, as seen in #1302 . 5 | 6 | async function build(params, options) { 7 | const { default: runCommand } = await import('./build.js'); 8 | return runCommand(params, options); 9 | } 10 | 11 | async function docs(params, options) { 12 | const { default: runCommand } = await import('./docs.js'); 13 | return runCommand(params, options); 14 | } 15 | 16 | async function dumpConfig(params, options) { 17 | const { default: runCommand } = await import('./dump-config.js'); 18 | return runCommand(params, options); 19 | } 20 | 21 | async function lint(params, options) { 22 | const { default: runCommand } = await import('./lint.js'); 23 | return runCommand(params, options); 24 | } 25 | 26 | async function run(params, options) { 27 | const { default: runCommand } = await import('./run.js'); 28 | return runCommand(params, options); 29 | } 30 | 31 | async function sign(params, options) { 32 | const { default: runCommand } = await import('./sign.js'); 33 | return runCommand(params, options); 34 | } 35 | 36 | export default { build, docs, dumpConfig, lint, run, sign }; 37 | -------------------------------------------------------------------------------- /src/cmd/lint.js: -------------------------------------------------------------------------------- 1 | import { createInstance as defaultLinterCreator } from 'addons-linter'; 2 | 3 | import { createLogger } from '../util/logger.js'; 4 | import { createFileFilter as defaultFileFilterCreator } from '../util/file-filter.js'; 5 | 6 | const log = createLogger(import.meta.url); 7 | 8 | // Lint command types and implementation. 9 | 10 | export default function lint( 11 | { 12 | artifactsDir, 13 | boring, 14 | ignoreFiles, 15 | metadata, 16 | output, 17 | pretty, 18 | privileged, 19 | sourceDir, 20 | selfHosted, 21 | verbose, 22 | warningsAsErrors, 23 | }, 24 | { 25 | createLinter = defaultLinterCreator, 26 | createFileFilter = defaultFileFilterCreator, 27 | shouldExitProgram = true, 28 | } = {}, 29 | ) { 30 | const fileFilter = createFileFilter({ sourceDir, ignoreFiles, artifactsDir }); 31 | 32 | const config = { 33 | logLevel: verbose ? 'debug' : 'fatal', 34 | stack: Boolean(verbose), 35 | pretty, 36 | privileged, 37 | warningsAsErrors, 38 | metadata, 39 | output, 40 | boring, 41 | selfHosted, 42 | shouldScanFile: (fileName) => fileFilter.wantFile(fileName), 43 | minManifestVersion: 2, 44 | maxManifestVersion: 3, 45 | // This mimics the first command line argument from yargs, which should be 46 | // the directory to the extension. 47 | _: [sourceDir], 48 | }; 49 | 50 | log.debug(`Running addons-linter on ${sourceDir}`); 51 | const linter = createLinter({ config, runAsBinary: shouldExitProgram }); 52 | return linter.run(); 53 | } 54 | -------------------------------------------------------------------------------- /src/cmd/run.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs/promises'; 3 | import nodeFs from 'fs'; 4 | 5 | import defaultBuildExtension from './build.js'; 6 | import { showDesktopNotification as defaultDesktopNotifications } from '../util/desktop-notifier.js'; 7 | import * as defaultFirefoxApp from '../firefox/index.js'; 8 | import { connectWithMaxRetries as defaultFirefoxClient } from '../firefox/remote.js'; 9 | import { createLogger } from '../util/logger.js'; 10 | import defaultGetValidatedManifest from '../util/manifest.js'; 11 | import { UsageError } from '../errors.js'; 12 | import { 13 | createExtensionRunner, 14 | defaultReloadStrategy, 15 | MultiExtensionRunner as DefaultMultiExtensionRunner, 16 | } from '../extension-runners/index.js'; 17 | 18 | const log = createLogger(import.meta.url); 19 | 20 | // Run command types and implementation. 21 | 22 | export default async function run( 23 | { 24 | artifactsDir, 25 | browserConsole = false, 26 | devtools = false, 27 | pref, 28 | firefox, 29 | firefoxProfile, 30 | profileCreateIfMissing, 31 | keepProfileChanges = false, 32 | ignoreFiles, 33 | noInput = false, 34 | noReload = false, 35 | preInstall = false, 36 | sourceDir, 37 | watchFile, 38 | watchIgnored, 39 | startUrl, 40 | target, 41 | args, 42 | // Android CLI options. 43 | adbBin, 44 | adbHost, 45 | adbPort, 46 | adbDevice, 47 | adbDiscoveryTimeout, 48 | adbRemoveOldArtifacts, 49 | firefoxApk, 50 | firefoxApkComponent, 51 | // Chromium CLI options. 52 | chromiumBinary, 53 | chromiumProfile, 54 | }, 55 | { 56 | buildExtension = defaultBuildExtension, 57 | desktopNotifications = defaultDesktopNotifications, 58 | firefoxApp = defaultFirefoxApp, 59 | firefoxClient = defaultFirefoxClient, 60 | reloadStrategy = defaultReloadStrategy, 61 | MultiExtensionRunner = DefaultMultiExtensionRunner, 62 | getValidatedManifest = defaultGetValidatedManifest, 63 | } = {}, 64 | ) { 65 | sourceDir = path.resolve(sourceDir); 66 | log.info(`Running web extension from ${sourceDir}`); 67 | if (preInstall) { 68 | log.info( 69 | "Disabled auto-reloading because it's not possible with " + 70 | '--pre-install', 71 | ); 72 | noReload = true; 73 | } 74 | 75 | if ( 76 | watchFile != null && 77 | (!Array.isArray(watchFile) || 78 | !watchFile.every((el) => typeof el === 'string')) 79 | ) { 80 | throw new UsageError('Unexpected watchFile type'); 81 | } 82 | 83 | // Create an alias for --pref since it has been transformed into an 84 | // object containing one or more preferences. 85 | const customPrefs = pref; 86 | const manifestData = await getValidatedManifest(sourceDir); 87 | 88 | const profileDir = firefoxProfile || chromiumProfile; 89 | 90 | if (profileCreateIfMissing) { 91 | if (!profileDir) { 92 | throw new UsageError( 93 | '--profile-create-if-missing requires ' + 94 | '--firefox-profile or --chromium-profile', 95 | ); 96 | } 97 | const isDir = nodeFs.existsSync(profileDir); 98 | if (isDir) { 99 | log.info(`Profile directory ${profileDir} already exists`); 100 | } else { 101 | log.info(`Profile directory not found. Creating directory ${profileDir}`); 102 | await fs.mkdir(profileDir); 103 | } 104 | } 105 | 106 | const runners = []; 107 | 108 | const commonRunnerParams = { 109 | // Common options. 110 | extensions: [{ sourceDir, manifestData }], 111 | keepProfileChanges, 112 | startUrl, 113 | args, 114 | desktopNotifications, 115 | }; 116 | 117 | if (!target || target.length === 0 || target.includes('firefox-desktop')) { 118 | const firefoxDesktopRunnerParams = { 119 | ...commonRunnerParams, 120 | 121 | // Firefox specific CLI options. 122 | firefoxBinary: firefox, 123 | profilePath: firefoxProfile, 124 | customPrefs, 125 | browserConsole, 126 | devtools, 127 | preInstall, 128 | 129 | // Firefox runner injected dependencies. 130 | firefoxApp, 131 | firefoxClient, 132 | }; 133 | 134 | const firefoxDesktopRunner = await createExtensionRunner({ 135 | target: 'firefox-desktop', 136 | params: firefoxDesktopRunnerParams, 137 | }); 138 | runners.push(firefoxDesktopRunner); 139 | } 140 | 141 | if (target && target.includes('firefox-android')) { 142 | const firefoxAndroidRunnerParams = { 143 | ...commonRunnerParams, 144 | 145 | // Firefox specific CLI options. 146 | profilePath: firefoxProfile, 147 | customPrefs, 148 | browserConsole, 149 | preInstall, 150 | firefoxApk, 151 | firefoxApkComponent, 152 | adbDevice, 153 | adbHost, 154 | adbPort, 155 | adbBin, 156 | adbDiscoveryTimeout, 157 | adbRemoveOldArtifacts, 158 | 159 | // Injected dependencies. 160 | firefoxApp, 161 | firefoxClient, 162 | desktopNotifications: defaultDesktopNotifications, 163 | buildSourceDir: (extensionSourceDir, tmpArtifactsDir) => { 164 | return buildExtension( 165 | { 166 | sourceDir: extensionSourceDir, 167 | ignoreFiles, 168 | asNeeded: false, 169 | // Use a separate temporary directory for building the extension zip file 170 | // that we are going to upload on the android device. 171 | artifactsDir: tmpArtifactsDir, 172 | }, 173 | { 174 | // Suppress the message usually logged by web-ext build. 175 | showReadyMessage: false, 176 | }, 177 | ); 178 | }, 179 | }; 180 | 181 | const firefoxAndroidRunner = await createExtensionRunner({ 182 | target: 'firefox-android', 183 | params: firefoxAndroidRunnerParams, 184 | }); 185 | runners.push(firefoxAndroidRunner); 186 | } 187 | 188 | if (target && target.includes('chromium')) { 189 | const chromiumRunnerParams = { 190 | ...commonRunnerParams, 191 | chromiumBinary, 192 | chromiumProfile, 193 | }; 194 | 195 | const chromiumRunner = await createExtensionRunner({ 196 | target: 'chromium', 197 | params: chromiumRunnerParams, 198 | }); 199 | runners.push(chromiumRunner); 200 | } 201 | 202 | const extensionRunner = new MultiExtensionRunner({ 203 | desktopNotifications, 204 | runners, 205 | }); 206 | 207 | await extensionRunner.run(); 208 | 209 | if (noReload) { 210 | log.info('Automatic extension reloading has been disabled'); 211 | } else { 212 | log.info('The extension will reload if any source file changes'); 213 | 214 | reloadStrategy({ 215 | extensionRunner, 216 | sourceDir, 217 | watchFile, 218 | watchIgnored, 219 | artifactsDir, 220 | ignoreFiles, 221 | noInput, 222 | }); 223 | } 224 | 225 | return extensionRunner; 226 | } 227 | -------------------------------------------------------------------------------- /src/cmd/sign.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import defaultBuilder from './build.js'; 4 | import { isErrorWithCode, UsageError, WebExtError } from '../errors.js'; 5 | import { prepareArtifactsDir } from '../util/artifacts.js'; 6 | import { createLogger } from '../util/logger.js'; 7 | import getValidatedManifest, { getManifestId } from '../util/manifest.js'; 8 | import { 9 | defaultAsyncFsReadFile, 10 | signAddon as defaultSubmitAddonSigner, 11 | } from '../util/submit-addon.js'; 12 | import { withTempDir } from '../util/temp-dir.js'; 13 | 14 | const log = createLogger(import.meta.url); 15 | 16 | export const extensionIdFile = '.web-extension-id'; 17 | export const uploadUuidFile = '.amo-upload-uuid'; 18 | 19 | // Sign command types and implementation. 20 | 21 | export default function sign( 22 | { 23 | amoBaseUrl, 24 | apiKey, 25 | apiProxy, 26 | apiSecret, 27 | artifactsDir, 28 | ignoreFiles = [], 29 | sourceDir, 30 | timeout, 31 | approvalTimeout, 32 | channel, 33 | amoMetadata, 34 | uploadSourceCode, 35 | webextVersion, 36 | }, 37 | { 38 | build = defaultBuilder, 39 | preValidatedManifest, 40 | submitAddon = defaultSubmitAddonSigner, 41 | asyncFsReadFile = defaultAsyncFsReadFile, 42 | } = {}, 43 | ) { 44 | return withTempDir(async function (tmpDir) { 45 | await prepareArtifactsDir(artifactsDir); 46 | 47 | let manifestData; 48 | const savedIdPath = path.join(sourceDir, extensionIdFile); 49 | const savedUploadUuidPath = path.join(sourceDir, uploadUuidFile); 50 | 51 | if (preValidatedManifest) { 52 | manifestData = preValidatedManifest; 53 | } else { 54 | manifestData = await getValidatedManifest(sourceDir); 55 | } 56 | 57 | const [buildResult, idFromSourceDir] = await Promise.all([ 58 | build( 59 | { sourceDir, ignoreFiles, artifactsDir: tmpDir.path() }, 60 | { manifestData, showReadyMessage: false }, 61 | ), 62 | getIdFromFile(savedIdPath), 63 | ]); 64 | 65 | const id = getManifestId(manifestData); 66 | if (idFromSourceDir && !id) { 67 | throw new UsageError( 68 | 'Cannot use previously auto-generated extension ID ' + 69 | `${idFromSourceDir} - This extension ID must be specified in the manifest.json file.`, 70 | ); 71 | } 72 | 73 | if (!id) { 74 | // We only auto-generate add-on IDs for MV2 add-ons on AMO. 75 | if (manifestData.manifest_version !== 2) { 76 | throw new UsageError( 77 | 'An extension ID must be specified in the manifest.json file.', 78 | ); 79 | } 80 | 81 | log.warn( 82 | 'No extension ID specified (it will be auto-generated the first time)', 83 | ); 84 | } 85 | 86 | if (!channel) { 87 | throw new UsageError('You must specify a channel'); 88 | } 89 | 90 | let metaDataJson; 91 | if (amoMetadata) { 92 | const metadataFileBuffer = await asyncFsReadFile(amoMetadata); 93 | try { 94 | metaDataJson = JSON.parse(metadataFileBuffer.toString()); 95 | } catch (err) { 96 | throw new UsageError('Invalid JSON in listing metadata'); 97 | } 98 | } 99 | const userAgentString = `web-ext/${webextVersion}`; 100 | 101 | const signSubmitArgs = { 102 | apiKey, 103 | apiSecret, 104 | apiProxy, 105 | id, 106 | xpiPath: buildResult.extensionPath, 107 | downloadDir: artifactsDir, 108 | channel, 109 | }; 110 | 111 | try { 112 | const result = await submitAddon({ 113 | ...signSubmitArgs, 114 | amoBaseUrl, 115 | channel, 116 | savedIdPath, 117 | savedUploadUuidPath, 118 | metaDataJson, 119 | userAgentString, 120 | validationCheckTimeout: timeout, 121 | approvalCheckTimeout: 122 | approvalTimeout !== undefined ? approvalTimeout : timeout, 123 | submissionSource: uploadSourceCode, 124 | }); 125 | 126 | return result; 127 | } catch (clientError) { 128 | throw new WebExtError(clientError.message); 129 | } 130 | }); 131 | } 132 | 133 | export async function getIdFromFile( 134 | filePath, 135 | asyncFsReadFile = defaultAsyncFsReadFile, 136 | ) { 137 | let content; 138 | 139 | try { 140 | content = await asyncFsReadFile(filePath); 141 | } catch (error) { 142 | if (isErrorWithCode('ENOENT', error)) { 143 | log.debug(`No ID file found at: ${filePath}`); 144 | return; 145 | } 146 | throw error; 147 | } 148 | 149 | let lines = content.toString().split('\n'); 150 | lines = lines.filter((line) => { 151 | line = line.trim(); 152 | if (line && !line.startsWith('#')) { 153 | return line; 154 | } 155 | }); 156 | 157 | const id = lines[0]; 158 | log.debug(`Found extension ID ${id} in ${filePath}`); 159 | 160 | if (!id) { 161 | throw new UsageError(`No ID found in extension ID file ${filePath}`); 162 | } 163 | 164 | return id; 165 | } 166 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import path from 'path'; 3 | import fs from 'fs/promises'; 4 | 5 | import camelCase from 'camelcase'; 6 | import decamelize from 'decamelize'; 7 | import parseJSON from 'parse-json'; 8 | 9 | import fileExists from './util/file-exists.js'; 10 | import { createLogger } from './util/logger.js'; 11 | import { UsageError, WebExtError } from './errors.js'; 12 | 13 | const log = createLogger(import.meta.url); 14 | 15 | // NOTE: this error message is used in an interpolated string (while the other two help 16 | // messages are being logged as is). 17 | export const WARN_LEGACY_JS_EXT = [ 18 | 'should be renamed to ".cjs" or ".mjs" file extension to ensure its format is not ambiguous.', 19 | 'Config files with the ".js" file extension are deprecated and will not be loaded anymore', 20 | 'in a future web-ext major version.', 21 | ].join(' '); 22 | 23 | export const HELP_ERR_MODULE_FROM_ESM = [ 24 | 'This config file belongs to a package.json file with "type" set to "module".', 25 | 'Change the file extension to ".cjs" or rewrite it as an ES module and use the ".mjs" file extension.', 26 | ].join(' '); 27 | 28 | export const HELP_ERR_IMPORTEXPORT_CJS = [ 29 | 'This config file is defined as an ES module, but it belongs to either a project directory', 30 | 'with a package.json file with "type" set to "commonjs" or one without any package.json file.', 31 | 'Change the file extension to ".mjs" to fix the config loading error.', 32 | ].join(' '); 33 | 34 | const ERR_IMPORT_FROM_CJS = 'Cannot use import statement outside a module'; 35 | const ERR_EXPORT_FROM_CJS = "Unexpected token 'export'"; 36 | const ERR_MODULE_FROM_ESM = 'module is not defined in ES module scope'; 37 | 38 | export function applyConfigToArgv({ 39 | argv, 40 | argvFromCLI, 41 | configObject, 42 | options, 43 | configFileName, 44 | }) { 45 | let newArgv = { ...argv }; 46 | 47 | for (const option of Object.keys(configObject)) { 48 | if (camelCase(option) !== option) { 49 | throw new UsageError( 50 | `The config option "${option}" must be ` + 51 | `specified in camel case: "${camelCase(option)}"`, 52 | ); 53 | } 54 | 55 | // A config option cannot be a sub-command config 56 | // object if it is an array. 57 | if ( 58 | !Array.isArray(configObject[option]) && 59 | typeof options[option] === 'object' && 60 | typeof configObject[option] === 'object' 61 | ) { 62 | // Descend into the nested configuration for a sub-command. 63 | newArgv = applyConfigToArgv({ 64 | argv: newArgv, 65 | argvFromCLI, 66 | configObject: configObject[option], 67 | options: options[option], 68 | configFileName, 69 | }); 70 | continue; 71 | } 72 | 73 | const decamelizedOptName = decamelize(option, { separator: '-' }); 74 | 75 | if (typeof options[decamelizedOptName] !== 'object') { 76 | throw new UsageError( 77 | `The config file at ${configFileName} specified ` + 78 | `an unknown option: "${option}"`, 79 | ); 80 | } 81 | if (options[decamelizedOptName].type === undefined) { 82 | // This means yargs option type wasn't not defined correctly 83 | throw new WebExtError(`Option: ${option} was defined without a type.`); 84 | } 85 | 86 | const expectedType = 87 | options[decamelizedOptName].type === 'count' 88 | ? 'number' 89 | : options[decamelizedOptName].type; 90 | 91 | const optionType = Array.isArray(configObject[option]) 92 | ? 'array' 93 | : typeof configObject[option]; 94 | 95 | if (optionType !== expectedType) { 96 | throw new UsageError( 97 | `The config file at ${configFileName} specified ` + 98 | `the type of "${option}" incorrectly as "${optionType}"` + 99 | ` (expected type "${expectedType}")`, 100 | ); 101 | } 102 | 103 | let defaultValue; 104 | if (options[decamelizedOptName]) { 105 | if (options[decamelizedOptName].default !== undefined) { 106 | defaultValue = options[decamelizedOptName].default; 107 | } else if (expectedType === 'boolean') { 108 | defaultValue = false; 109 | } 110 | } 111 | 112 | // This is our best effort (without patching yargs) to detect 113 | // if a value was set on the CLI instead of in the config. 114 | // It looks for a default value and if the argv value is 115 | // different, it assumes that the value was configured on the CLI. 116 | 117 | const wasValueSetOnCLI = 118 | typeof argvFromCLI[option] !== 'undefined' && 119 | argvFromCLI[option] !== defaultValue; 120 | if (wasValueSetOnCLI) { 121 | log.debug( 122 | `Favoring CLI: ${option}=${argvFromCLI[option]} over ` + 123 | `configuration: ${option}=${configObject[option]}`, 124 | ); 125 | newArgv[option] = argvFromCLI[option]; 126 | continue; 127 | } 128 | 129 | newArgv[option] = configObject[option]; 130 | 131 | const coerce = options[decamelizedOptName].coerce; 132 | if (coerce) { 133 | log.debug(`Calling coerce() on configured value for ${option}`); 134 | newArgv[option] = coerce(newArgv[option]); 135 | } 136 | 137 | newArgv[decamelizedOptName] = newArgv[option]; 138 | } 139 | return newArgv; 140 | } 141 | 142 | export async function loadJSConfigFile(filePath) { 143 | const resolvedFilePath = path.resolve(filePath); 144 | log.debug( 145 | `Loading JS config file: "${filePath}" ` + 146 | `(resolved to "${resolvedFilePath}")`, 147 | ); 148 | if (filePath.endsWith('.js')) { 149 | log.warn(`WARNING: config file ${filePath} ${WARN_LEGACY_JS_EXT}`); 150 | } 151 | let configObject; 152 | try { 153 | const nonce = `${Date.now()}-${Math.random()}`; 154 | let configModule; 155 | if (resolvedFilePath.endsWith('package.json')) { 156 | configModule = parseJSON( 157 | await fs.readFile(resolvedFilePath, { encoding: 'utf-8' }), 158 | ); 159 | } else { 160 | configModule = await import(`file://${resolvedFilePath}?nonce=${nonce}`); 161 | } 162 | 163 | if (configModule.default) { 164 | const { default: configDefault, ...esmConfigMod } = configModule; 165 | // ES modules may expose both a default and named exports and so 166 | // we merge the named exports on top of what may have been set in 167 | // the default export. 168 | configObject = { ...configDefault, ...esmConfigMod }; 169 | } else { 170 | configObject = { ...configModule }; 171 | } 172 | } catch (error) { 173 | log.debug('Handling error:', error); 174 | let errorMessage = error.message; 175 | if (error.message.startsWith(ERR_MODULE_FROM_ESM)) { 176 | errorMessage = HELP_ERR_MODULE_FROM_ESM; 177 | } else if ( 178 | [ERR_IMPORT_FROM_CJS, ERR_EXPORT_FROM_CJS].includes(error.message) 179 | ) { 180 | errorMessage = HELP_ERR_IMPORTEXPORT_CJS; 181 | } 182 | throw new UsageError( 183 | `Cannot read config file: ${resolvedFilePath}\n` + 184 | `Error: ${errorMessage}`, 185 | ); 186 | } 187 | if (filePath.endsWith('package.json')) { 188 | log.debug('Looking for webExt key inside package.json file'); 189 | configObject = configObject.webExt || {}; 190 | } 191 | if (Object.keys(configObject).length === 0) { 192 | log.debug( 193 | `Config file ${resolvedFilePath} did not define any options. ` + 194 | 'Did you set module.exports = {...}?', 195 | ); 196 | } 197 | return configObject; 198 | } 199 | 200 | export async function discoverConfigFiles({ getHomeDir = os.homedir } = {}) { 201 | const magicConfigName = 'web-ext-config'; 202 | 203 | // Config files will be loaded in this order. 204 | const possibleConfigs = [ 205 | // Look for a magic hidden config (preceded by dot) in home dir. 206 | path.join(getHomeDir(), `.${magicConfigName}.mjs`), 207 | path.join(getHomeDir(), `.${magicConfigName}.cjs`), 208 | path.join(getHomeDir(), `.${magicConfigName}.js`), 209 | // Look for webExt key inside package.json file 210 | path.join(process.cwd(), 'package.json'), 211 | // Look for a magic config in the current working directory. 212 | path.join(process.cwd(), `${magicConfigName}.mjs`), 213 | path.join(process.cwd(), `${magicConfigName}.cjs`), 214 | path.join(process.cwd(), `${magicConfigName}.js`), 215 | // Look for a magic hidden config (preceded by dot) the current working directory. 216 | path.join(process.cwd(), `.${magicConfigName}.mjs`), 217 | path.join(process.cwd(), `.${magicConfigName}.cjs`), 218 | path.join(process.cwd(), `.${magicConfigName}.js`), 219 | ]; 220 | 221 | const configs = await Promise.all( 222 | possibleConfigs.map(async (fileName) => { 223 | const resolvedFileName = path.resolve(fileName); 224 | if (await fileExists(resolvedFileName)) { 225 | return resolvedFileName; 226 | } else { 227 | log.debug( 228 | `Discovered config "${resolvedFileName}" does not ` + 229 | 'exist or is not readable', 230 | ); 231 | return undefined; 232 | } 233 | }), 234 | ); 235 | 236 | const existingConfigs = []; 237 | configs.forEach((f) => { 238 | if (typeof f === 'string') { 239 | existingConfigs.push(f); 240 | } 241 | }); 242 | return existingConfigs; 243 | } 244 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | import ExtendableError from 'es6-error'; 2 | 3 | /* 4 | * Base error for all custom web-ext errors. 5 | */ 6 | export class WebExtError extends ExtendableError { 7 | constructor(message) { 8 | super(message); 9 | } 10 | } 11 | 12 | /* 13 | * The class for errors that can be fixed by the developer. 14 | */ 15 | export class UsageError extends WebExtError { 16 | constructor(message) { 17 | super(message); 18 | } 19 | } 20 | 21 | /* 22 | * The manifest for the extension is invalid (or missing). 23 | */ 24 | export class InvalidManifest extends UsageError { 25 | constructor(message) { 26 | super(message); 27 | } 28 | } 29 | 30 | /* 31 | * The remote Firefox does not support temporary add-on installation. 32 | */ 33 | export class RemoteTempInstallNotSupported extends WebExtError { 34 | constructor(message) { 35 | super(message); 36 | } 37 | } 38 | 39 | /* 40 | * The errors collected when reloading all extensions at once 41 | * (initialized from a map of errors by extensionSourceDir string). 42 | */ 43 | export class MultiExtensionsReloadError extends WebExtError { 44 | constructor(errorsMap) { 45 | let errors = ''; 46 | for (const [sourceDir, error] of errorsMap) { 47 | const msg = String(error); 48 | errors += `\nError on extension loaded from ${sourceDir}: ${msg}\n`; 49 | } 50 | const message = `Reload errors: ${errors}`; 51 | 52 | super(message); 53 | this.errorsBySourceDir = errorsMap; 54 | } 55 | } 56 | 57 | /* 58 | * Sugar-y way to catch only instances of a certain error. 59 | * 60 | * Usage: 61 | * 62 | * Promise.reject(SyntaxError) 63 | * .catch(onlyInstancesOf(SyntaxError, (error) => { 64 | * // error is guaranteed to be an instance of SyntaxError 65 | * })) 66 | * 67 | * All other errors will be re-thrown. 68 | * 69 | */ 70 | export function onlyInstancesOf(predicate, errorHandler) { 71 | return (error) => { 72 | if (error instanceof predicate) { 73 | return errorHandler(error); 74 | } else { 75 | throw error; 76 | } 77 | }; 78 | } 79 | 80 | /* 81 | * Sugar-y way to catch only errors having certain code(s). 82 | * 83 | * Usage: 84 | * 85 | * Promise.resolve() 86 | * .catch(onlyErrorsWithCode('ENOENT', (error) => { 87 | * // error.code is guaranteed to be ENOENT 88 | * })) 89 | * 90 | * or: 91 | * 92 | * Promise.resolve() 93 | * .catch(onlyErrorsWithCode(['ENOENT', 'ENOTDIR'], (error) => { 94 | * // ... 95 | * })) 96 | * 97 | * All other errors will be re-thrown. 98 | * 99 | */ 100 | export function onlyErrorsWithCode(codeWanted, errorHandler) { 101 | return (error) => { 102 | let throwError = true; 103 | 104 | if (Array.isArray(codeWanted)) { 105 | if ( 106 | codeWanted.indexOf(error.code) !== -1 || 107 | codeWanted.indexOf(error.errno) !== -1 108 | ) { 109 | throwError = false; 110 | } 111 | } else if (error.code === codeWanted || error.errno === codeWanted) { 112 | throwError = false; 113 | } 114 | 115 | if (throwError) { 116 | throw error; 117 | } 118 | 119 | return errorHandler(error); 120 | }; 121 | } 122 | 123 | export function isErrorWithCode(codeWanted, error) { 124 | if (Array.isArray(codeWanted) && codeWanted.indexOf(error.code) !== -1) { 125 | return true; 126 | } else if (error.code === codeWanted) { 127 | return true; 128 | } 129 | 130 | return false; 131 | } 132 | -------------------------------------------------------------------------------- /src/extension-runners/firefox-desktop.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module provide an ExtensionRunner subclass that manage an extension executed 3 | * in a Firefox for Desktop instance. 4 | */ 5 | 6 | import { 7 | MultiExtensionsReloadError, 8 | RemoteTempInstallNotSupported, 9 | WebExtError, 10 | } from '../errors.js'; 11 | import { createLogger } from '../util/logger.js'; 12 | 13 | const log = createLogger(import.meta.url); 14 | 15 | /** 16 | * Implements an IExtensionRunner which manages a Firefox Desktop instance. 17 | */ 18 | export class FirefoxDesktopExtensionRunner { 19 | cleanupCallbacks; 20 | params; 21 | profile; 22 | // Map extensions sourceDir to their related addon ids. 23 | reloadableExtensions; 24 | remoteFirefox; 25 | runningInfo; 26 | 27 | constructor(params) { 28 | this.params = params; 29 | 30 | this.reloadableExtensions = new Map(); 31 | this.cleanupCallbacks = new Set(); 32 | } 33 | 34 | // Method exported from the IExtensionRunner interface. 35 | 36 | /** 37 | * Returns the runner name. 38 | */ 39 | getName() { 40 | return 'Firefox Desktop'; 41 | } 42 | 43 | /** 44 | * Setup the Firefox Profile and run a Firefox Desktop instance. 45 | */ 46 | async run() { 47 | // Get a firefox profile with the custom Prefs set (a new or a cloned one). 48 | // Pre-install extensions as proxy if needed (and disable auto-reload if you do) 49 | await this.setupProfileDir(); 50 | 51 | // (if reload is enabled): 52 | // - Connect to the firefox instance on RDP 53 | // - Install any extension if needed (if not installed as proxy) 54 | // - Keep track of the extension id assigned in a map with the sourceDir as a key 55 | await this.startFirefoxInstance(); 56 | } 57 | 58 | /** 59 | * Reloads all the extensions, collect any reload error and resolves to 60 | * an array composed by a single ExtensionRunnerReloadResult object. 61 | */ 62 | async reloadAllExtensions() { 63 | const runnerName = this.getName(); 64 | const reloadErrors = new Map(); 65 | for (const { sourceDir } of this.params.extensions) { 66 | const [res] = await this.reloadExtensionBySourceDir(sourceDir); 67 | if (res.reloadError instanceof Error) { 68 | reloadErrors.set(sourceDir, res.reloadError); 69 | } 70 | } 71 | 72 | if (reloadErrors.size > 0) { 73 | return [ 74 | { 75 | runnerName, 76 | reloadError: new MultiExtensionsReloadError(reloadErrors), 77 | }, 78 | ]; 79 | } 80 | 81 | return [{ runnerName }]; 82 | } 83 | 84 | /** 85 | * Reloads a single extension, collect any reload error and resolves to 86 | * an array composed by a single ExtensionRunnerReloadResult object. 87 | */ 88 | async reloadExtensionBySourceDir(extensionSourceDir) { 89 | const runnerName = this.getName(); 90 | const addonId = this.reloadableExtensions.get(extensionSourceDir); 91 | 92 | if (!addonId) { 93 | return [ 94 | { 95 | sourceDir: extensionSourceDir, 96 | reloadError: new WebExtError( 97 | 'Extension not reloadable: ' + 98 | `no addonId has been mapped to "${extensionSourceDir}"`, 99 | ), 100 | runnerName, 101 | }, 102 | ]; 103 | } 104 | 105 | try { 106 | await this.remoteFirefox.reloadAddon(addonId); 107 | } catch (error) { 108 | return [ 109 | { 110 | sourceDir: extensionSourceDir, 111 | reloadError: error, 112 | runnerName, 113 | }, 114 | ]; 115 | } 116 | 117 | return [{ runnerName, sourceDir: extensionSourceDir }]; 118 | } 119 | 120 | /** 121 | * Register a callback to be called when the runner has been exited 122 | * (e.g. the Firefox instance exits or the user has requested web-ext 123 | * to exit). 124 | */ 125 | registerCleanup(fn) { 126 | this.cleanupCallbacks.add(fn); 127 | } 128 | 129 | /** 130 | * Exits the runner, by closing the managed Firefox instance. 131 | */ 132 | async exit() { 133 | if (!this.runningInfo || !this.runningInfo.firefox) { 134 | throw new WebExtError('No firefox instance is currently running'); 135 | } 136 | 137 | this.runningInfo.firefox.kill(); 138 | } 139 | 140 | // Private helper methods. 141 | 142 | async setupProfileDir() { 143 | const { 144 | customPrefs, 145 | extensions, 146 | keepProfileChanges, 147 | preInstall, 148 | profilePath, 149 | firefoxApp, 150 | } = this.params; 151 | 152 | if (profilePath) { 153 | if (keepProfileChanges) { 154 | log.debug(`Using Firefox profile from ${profilePath}`); 155 | this.profile = await firefoxApp.useProfile(profilePath, { 156 | customPrefs, 157 | }); 158 | } else { 159 | log.debug(`Copying Firefox profile from ${profilePath}`); 160 | this.profile = await firefoxApp.copyProfile(profilePath, { 161 | customPrefs, 162 | }); 163 | } 164 | } else { 165 | log.debug('Creating new Firefox profile'); 166 | this.profile = await firefoxApp.createProfile({ customPrefs }); 167 | } 168 | 169 | // preInstall the extensions if needed. 170 | if (preInstall) { 171 | for (const extension of extensions) { 172 | await firefoxApp.installExtension({ 173 | asProxy: true, 174 | extensionPath: extension.sourceDir, 175 | manifestData: extension.manifestData, 176 | profile: this.profile, 177 | }); 178 | } 179 | } 180 | } 181 | 182 | async startFirefoxInstance() { 183 | const { 184 | browserConsole, 185 | devtools, 186 | extensions, 187 | firefoxBinary, 188 | preInstall, 189 | startUrl, 190 | firefoxApp, 191 | firefoxClient, 192 | args, 193 | } = this.params; 194 | 195 | const binaryArgs = []; 196 | 197 | if (browserConsole) { 198 | binaryArgs.push('-jsconsole'); 199 | } 200 | if (startUrl) { 201 | const urls = Array.isArray(startUrl) ? startUrl : [startUrl]; 202 | for (const url of urls) { 203 | binaryArgs.push('--url', url); 204 | } 205 | } 206 | 207 | if (args) { 208 | binaryArgs.push(...args); 209 | } 210 | 211 | this.runningInfo = await firefoxApp.run(this.profile, { 212 | firefoxBinary, 213 | binaryArgs, 214 | extensions, 215 | devtools, 216 | }); 217 | 218 | this.runningInfo.firefox.on('close', () => { 219 | for (const cleanupCb of this.cleanupCallbacks) { 220 | try { 221 | cleanupCb(); 222 | } catch (error) { 223 | log.error(`Exception on executing cleanup callback: ${error}`); 224 | } 225 | } 226 | }); 227 | 228 | if (!preInstall) { 229 | const remoteFirefox = (this.remoteFirefox = await firefoxClient({ 230 | port: this.runningInfo.debuggerPort, 231 | })); 232 | 233 | // Install all the temporary addons. 234 | for (const extension of extensions) { 235 | try { 236 | const addonId = await remoteFirefox 237 | .installTemporaryAddon(extension.sourceDir, devtools) 238 | .then((installResult) => { 239 | return installResult.addon.id; 240 | }); 241 | 242 | if (!addonId) { 243 | throw new WebExtError( 244 | 'Unexpected missing addonId in the installAsTemporaryAddon result', 245 | ); 246 | } 247 | 248 | this.reloadableExtensions.set(extension.sourceDir, addonId); 249 | } catch (error) { 250 | if (error instanceof RemoteTempInstallNotSupported) { 251 | log.debug(`Caught: ${String(error)}`); 252 | throw new WebExtError( 253 | 'Temporary add-on installation is not supported in this version' + 254 | ' of Firefox (you need Firefox 49 or higher). For older Firefox' + 255 | ' versions, use --pre-install', 256 | ); 257 | } else { 258 | throw error; 259 | } 260 | } 261 | } 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/firefox/package-identifiers.js: -------------------------------------------------------------------------------- 1 | // This list should have more specific identifiers listed first because of the 2 | // logic in `src/util/adb.js`, e.g. `some.id.debug` is more specific than `some.id`. 3 | export default [ 4 | 'org.mozilla.fennec', 5 | 'org.mozilla.fenix.debug', 6 | 'org.mozilla.fenix', 7 | 'org.mozilla.geckoview_example', 8 | 'org.mozilla.geckoview', 9 | 'org.mozilla.firefox', 10 | 'org.mozilla.reference.browser', 11 | ]; 12 | 13 | export const defaultApkComponents = { 14 | 'org.mozilla.reference.browser': '.BrowserActivity', 15 | }; 16 | -------------------------------------------------------------------------------- /src/firefox/preferences.js: -------------------------------------------------------------------------------- 1 | import { WebExtError, UsageError } from '../errors.js'; 2 | import { createLogger } from '../util/logger.js'; 3 | 4 | const log = createLogger(import.meta.url); 5 | 6 | export const nonOverridablePreferences = [ 7 | 'devtools.debugger.remote-enabled', 8 | 'devtools.debugger.prompt-connection', 9 | 'xpinstall.signatures.required', 10 | ]; 11 | 12 | // Preferences Maps 13 | 14 | const prefsCommon = { 15 | // Allow debug output via dump to be printed to the system console 16 | 'browser.dom.window.dump.enabled': true, 17 | 18 | // From: 19 | // https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/internals/preferences.html#data-choices-notification 20 | // This is the data submission master kill switch. If disabled, no policy is shown or upload takes place, ever. 21 | 'datareporting.policy.dataSubmissionEnabled': false, 22 | 23 | // Allow remote connections to the debugger. 24 | 'devtools.debugger.remote-enabled': true, 25 | // Disable the prompt for allowing connections. 26 | 'devtools.debugger.prompt-connection': false, 27 | // Allow extensions to log messages on browser's console. 28 | 'devtools.browserconsole.contentMessages': true, 29 | 30 | // Turn off platform logging because it is a lot of info. 31 | 'extensions.logging.enabled': false, 32 | 33 | // Disable extension updates and notifications. 34 | 'extensions.checkCompatibility.nightly': false, 35 | 'extensions.update.enabled': false, 36 | 'extensions.update.notifyUser': false, 37 | 38 | // From: 39 | // http://hg.mozilla.org/mozilla-central/file/1dd81c324ac7/build/automation.py.in//l372 40 | // Only load extensions from the application and user profile. 41 | // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION 42 | 'extensions.enabledScopes': 5, 43 | // Disable metadata caching for installed add-ons by default. 44 | 'extensions.getAddons.cache.enabled': false, 45 | // Disable installing any distribution add-ons. 46 | 'extensions.installDistroAddons': false, 47 | // Allow installing extensions dropped into the profile folder. 48 | 'extensions.autoDisableScopes': 10, 49 | 50 | // Disable app update. 51 | 'app.update.enabled': false, 52 | 53 | // Allow unsigned add-ons. 54 | 'xpinstall.signatures.required': false, 55 | 56 | // browser.link.open_newwindow is changed from 3 to 2 in: 57 | // https://github.com/saadtazi/firefox-profile-js/blob/cafc793d940a779d280103ae17d02a92de862efc/lib/firefox_profile.js#L32 58 | // Restore original value to avoid https://github.com/mozilla/web-ext/issues/1592 59 | 'browser.link.open_newwindow': 3, 60 | }; 61 | 62 | // Prefs specific to Firefox for Android. 63 | const prefsFennec = { 64 | 'browser.console.showInPanel': true, 65 | 'browser.firstrun.show.uidiscovery': false, 66 | 'devtools.remote.usb.enabled': true, 67 | }; 68 | 69 | // Prefs specific to Firefox for desktop. 70 | const prefsFirefox = { 71 | 'browser.startup.homepage': 'about:blank', 72 | 'startup.homepage_welcome_url': 'about:blank', 73 | 'startup.homepage_welcome_url.additional': '', 74 | 'devtools.errorconsole.enabled': true, 75 | 'devtools.chrome.enabled': true, 76 | 77 | // From: 78 | // http://hg.mozilla.org/mozilla-central/file/1dd81c324ac7/build/automation.py.in//l388 79 | // Make url-classifier updates so rare that they won't affect tests. 80 | 'urlclassifier.updateinterval': 172800, 81 | // Point the url-classifier to a nonexistent local URL for fast failures. 82 | 'browser.safebrowsing.provider.0.gethashURL': 83 | 'http://localhost/safebrowsing-dummy/gethash', 84 | 'browser.safebrowsing.provider.0.keyURL': 85 | 'http://localhost/safebrowsing-dummy/newkey', 86 | 'browser.safebrowsing.provider.0.updateURL': 87 | 'http://localhost/safebrowsing-dummy/update', 88 | 89 | // Disable self repair/SHIELD 90 | 'browser.selfsupport.url': 'https://localhost/selfrepair', 91 | // Disable Reader Mode UI tour 92 | 'browser.reader.detectedFirstArticle': true, 93 | 94 | // Set the policy firstURL to an empty string to prevent 95 | // the privacy info page to be opened on every "web-ext run". 96 | // (See #1114 for rationale) 97 | 'datareporting.policy.firstRunURL': '', 98 | }; 99 | 100 | const prefs = { 101 | common: prefsCommon, 102 | fennec: prefsFennec, 103 | firefox: prefsFirefox, 104 | }; 105 | 106 | // Module exports 107 | 108 | export function getPrefs(app = 'firefox') { 109 | const appPrefs = prefs[app]; 110 | if (!appPrefs) { 111 | throw new WebExtError(`Unsupported application: ${app}`); 112 | } 113 | return { 114 | ...prefsCommon, 115 | ...appPrefs, 116 | }; 117 | } 118 | 119 | export function coerceCLICustomPreference(cliPrefs) { 120 | const customPrefs = {}; 121 | 122 | for (const pref of cliPrefs) { 123 | const prefsAry = pref.split('='); 124 | 125 | if (prefsAry.length < 2) { 126 | throw new UsageError( 127 | `Incomplete custom preference: "${pref}". ` + 128 | 'Syntax expected: "prefname=prefvalue".', 129 | ); 130 | } 131 | 132 | const key = prefsAry[0]; 133 | let value = prefsAry.slice(1).join('='); 134 | 135 | if (/[^\w{@}.-]/.test(key)) { 136 | throw new UsageError(`Invalid custom preference name: ${key}`); 137 | } 138 | 139 | if (value === `${parseInt(value)}`) { 140 | value = parseInt(value, 10); 141 | } else if (value === 'true' || value === 'false') { 142 | value = value === 'true'; 143 | } 144 | 145 | if (nonOverridablePreferences.includes(key)) { 146 | log.warn(`'${key}' preference cannot be customized.`); 147 | continue; 148 | } 149 | customPrefs[`${key}`] = value; 150 | } 151 | 152 | return customPrefs; 153 | } 154 | -------------------------------------------------------------------------------- /src/firefox/rdp-client.js: -------------------------------------------------------------------------------- 1 | import net from 'net'; 2 | import EventEmitter from 'events'; 3 | import domain from 'domain'; 4 | 5 | export const DEFAULT_PORT = 6000; 6 | export const DEFAULT_HOST = '127.0.0.1'; 7 | 8 | const UNSOLICITED_EVENTS = new Set([ 9 | 'tabNavigated', 10 | 'styleApplied', 11 | 'propertyChange', 12 | 'networkEventUpdate', 13 | 'networkEvent', 14 | 'propertyChange', 15 | 'newMutations', 16 | 'frameUpdate', 17 | 'tabListChanged', 18 | ]); 19 | 20 | // Parse RDP packets: BYTE_LENGTH + ':' + DATA. 21 | export function parseRDPMessage(data) { 22 | const str = data.toString(); 23 | const sepIdx = str.indexOf(':'); 24 | if (sepIdx < 1) { 25 | return { data }; 26 | } 27 | 28 | const byteLen = parseInt(str.slice(0, sepIdx)); 29 | if (isNaN(byteLen)) { 30 | const error = new Error('Error parsing RDP message length'); 31 | return { data, error, fatal: true }; 32 | } 33 | 34 | if (data.length - (sepIdx + 1) < byteLen) { 35 | // Can't parse yet, will retry once more data has been received. 36 | return { data }; 37 | } 38 | 39 | data = data.slice(sepIdx + 1); 40 | const msg = data.slice(0, byteLen); 41 | data = data.slice(byteLen); 42 | 43 | try { 44 | return { data, rdpMessage: JSON.parse(msg.toString()) }; 45 | } catch (error) { 46 | return { data, error, fatal: false }; 47 | } 48 | } 49 | 50 | export async function connectToFirefox(port) { 51 | const client = new FirefoxRDPClient(); 52 | return client.connect(port).then(() => client); 53 | } 54 | 55 | export default class FirefoxRDPClient extends EventEmitter { 56 | _incoming; 57 | _pending; 58 | _active; 59 | _rdpConnection; 60 | _onData; 61 | _onError; 62 | _onEnd; 63 | _onTimeout; 64 | 65 | constructor() { 66 | super(); 67 | this._incoming = Buffer.alloc(0); 68 | this._pending = []; 69 | this._active = new Map(); 70 | 71 | this._onData = (...args) => this.onData(...args); 72 | this._onError = (...args) => this.onError(...args); 73 | this._onEnd = (...args) => this.onEnd(...args); 74 | this._onTimeout = (...args) => this.onTimeout(...args); 75 | } 76 | 77 | connect(port) { 78 | return new Promise((resolve, reject) => { 79 | // Create a domain to wrap the errors that may be triggered 80 | // by creating the client connection (e.g. ECONNREFUSED) 81 | // so that we can reject the promise returned instead of 82 | // exiting the entire process. 83 | const d = domain.create(); 84 | d.once('error', reject); 85 | d.run(() => { 86 | const conn = net.createConnection({ 87 | port, 88 | host: DEFAULT_HOST, 89 | }); 90 | 91 | this._rdpConnection = conn; 92 | conn.on('data', this._onData); 93 | conn.on('error', this._onError); 94 | conn.on('end', this._onEnd); 95 | conn.on('timeout', this._onTimeout); 96 | 97 | // Resolve once the expected initial root message 98 | // has been received. 99 | this._expectReply('root', { resolve, reject }); 100 | }); 101 | }); 102 | } 103 | 104 | disconnect() { 105 | if (!this._rdpConnection) { 106 | return; 107 | } 108 | 109 | const conn = this._rdpConnection; 110 | conn.off('data', this._onData); 111 | conn.off('error', this._onError); 112 | conn.off('end', this._onEnd); 113 | conn.off('timeout', this._onTimeout); 114 | conn.end(); 115 | 116 | this._rejectAllRequests(new Error('RDP connection closed')); 117 | } 118 | 119 | _rejectAllRequests(error) { 120 | for (const activeDeferred of this._active.values()) { 121 | activeDeferred.reject(error); 122 | } 123 | this._active.clear(); 124 | 125 | for (const { deferred } of this._pending) { 126 | deferred.reject(error); 127 | } 128 | this._pending = []; 129 | } 130 | 131 | async request(requestProps) { 132 | let request; 133 | 134 | if (typeof requestProps === 'string') { 135 | request = { to: 'root', type: requestProps }; 136 | } else { 137 | request = requestProps; 138 | } 139 | 140 | if (request.to == null) { 141 | throw new Error( 142 | `Unexpected RDP request without target actor: ${request.type}`, 143 | ); 144 | } 145 | 146 | return new Promise((resolve, reject) => { 147 | const deferred = { resolve, reject }; 148 | this._pending.push({ request, deferred }); 149 | this._flushPendingRequests(); 150 | }); 151 | } 152 | 153 | _flushPendingRequests() { 154 | this._pending = this._pending.filter(({ request, deferred }) => { 155 | if (this._active.has(request.to)) { 156 | // Keep in the pending requests until there are no requests 157 | // active on the target RDP actor. 158 | return true; 159 | } 160 | 161 | const conn = this._rdpConnection; 162 | if (!conn) { 163 | throw new Error('RDP connection closed'); 164 | } 165 | 166 | try { 167 | let str = JSON.stringify(request); 168 | str = `${Buffer.from(str).length}:${str}`; 169 | conn.write(str); 170 | this._expectReply(request.to, deferred); 171 | } catch (err) { 172 | deferred.reject(err); 173 | } 174 | 175 | // Remove the pending request from the queue. 176 | return false; 177 | }); 178 | } 179 | 180 | _expectReply(targetActor, deferred) { 181 | if (this._active.has(targetActor)) { 182 | throw new Error(`${targetActor} does already have an active request`); 183 | } 184 | 185 | this._active.set(targetActor, deferred); 186 | } 187 | 188 | _handleMessage(rdpData) { 189 | if (rdpData.from == null) { 190 | if (rdpData.error) { 191 | this.emit('rdp-error', rdpData); 192 | return; 193 | } 194 | 195 | this.emit( 196 | 'error', 197 | new Error( 198 | `Received an RDP message without a sender actor: ${JSON.stringify( 199 | rdpData, 200 | )}`, 201 | ), 202 | ); 203 | return; 204 | } 205 | 206 | if (UNSOLICITED_EVENTS.has(rdpData.type)) { 207 | this.emit('unsolicited-event', rdpData); 208 | return; 209 | } 210 | 211 | if (this._active.has(rdpData.from)) { 212 | const deferred = this._active.get(rdpData.from); 213 | this._active.delete(rdpData.from); 214 | if (rdpData.error) { 215 | deferred?.reject(rdpData); 216 | } else { 217 | deferred?.resolve(rdpData); 218 | } 219 | this._flushPendingRequests(); 220 | return; 221 | } 222 | 223 | this.emit( 224 | 'error', 225 | new Error(`Unexpected RDP message received: ${JSON.stringify(rdpData)}`), 226 | ); 227 | } 228 | 229 | _readMessage() { 230 | const { data, rdpMessage, error, fatal } = parseRDPMessage(this._incoming); 231 | 232 | this._incoming = data; 233 | 234 | if (error) { 235 | this.emit( 236 | 'error', 237 | new Error(`Error parsing RDP packet: ${String(error)}`), 238 | ); 239 | // Disconnect automatically on a fatal error. 240 | if (fatal) { 241 | this.disconnect(); 242 | } 243 | // Caller can parse the next message if the error wasn't fatal 244 | // (e.g. the RDP packet that couldn't be parsed has been already 245 | // removed from the incoming data buffer). 246 | return !fatal; 247 | } 248 | 249 | if (!rdpMessage) { 250 | // Caller will need to wait more data to parse the next message. 251 | return false; 252 | } 253 | 254 | this._handleMessage(rdpMessage); 255 | // Caller can try to parse the next message from the remaining data. 256 | return true; 257 | } 258 | 259 | onData(data) { 260 | this._incoming = Buffer.concat([this._incoming, data]); 261 | while (this._readMessage()) { 262 | // Keep parsing and handling messages until readMessage 263 | // returns false. 264 | } 265 | } 266 | 267 | onError(error) { 268 | this.emit('error', error); 269 | } 270 | 271 | onEnd() { 272 | this.emit('end'); 273 | } 274 | 275 | onTimeout() { 276 | this.emit('timeout'); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/firefox/remote.js: -------------------------------------------------------------------------------- 1 | import net from 'net'; 2 | 3 | import { connectToFirefox as defaultFirefoxConnector } from './rdp-client.js'; 4 | import { createLogger } from '../util/logger.js'; 5 | import { 6 | isErrorWithCode, 7 | RemoteTempInstallNotSupported, 8 | UsageError, 9 | WebExtError, 10 | } from '../errors.js'; 11 | 12 | const log = createLogger(import.meta.url); 13 | 14 | // NOTE: this type aliases Object to catch any other possible response. 15 | 16 | // Convert a request rejection to a message string. 17 | function requestErrorToMessage(err) { 18 | if (err instanceof Error) { 19 | return String(err); 20 | } 21 | return `${err.error}: ${err.message}`; 22 | } 23 | 24 | export class RemoteFirefox { 25 | client; 26 | checkedForAddonReloading; 27 | 28 | constructor(client) { 29 | this.client = client; 30 | this.checkedForAddonReloading = false; 31 | 32 | client.on('disconnect', () => { 33 | log.debug('Received "disconnect" from Firefox client'); 34 | }); 35 | client.on('end', () => { 36 | log.debug('Received "end" from Firefox client'); 37 | }); 38 | client.on('unsolicited-event', (info) => { 39 | log.debug(`Received message from client: ${JSON.stringify(info)}`); 40 | }); 41 | client.on('rdp-error', (rdpError) => { 42 | log.debug(`Received error from client: ${JSON.stringify(rdpError)}`); 43 | }); 44 | client.on('error', (error) => { 45 | log.debug(`Received error from client: ${String(error)}`); 46 | }); 47 | } 48 | 49 | disconnect() { 50 | this.client.disconnect(); 51 | } 52 | 53 | async addonRequest(addon, request) { 54 | try { 55 | const response = await this.client.request({ 56 | to: addon.actor, 57 | type: request, 58 | }); 59 | return response; 60 | } catch (err) { 61 | log.debug(`Client responded to '${request}' request with error:`, err); 62 | const message = requestErrorToMessage(err); 63 | throw new WebExtError(`Remote Firefox: addonRequest() error: ${message}`); 64 | } 65 | } 66 | 67 | async getAddonsActor() { 68 | try { 69 | // getRoot should work since Firefox 55 (bug 1352157). 70 | const response = await this.client.request('getRoot'); 71 | if (response.addonsActor == null) { 72 | return Promise.reject( 73 | new RemoteTempInstallNotSupported( 74 | 'This version of Firefox does not provide an add-ons actor for ' + 75 | 'remote installation.', 76 | ), 77 | ); 78 | } 79 | return response.addonsActor; 80 | } catch (err) { 81 | // Fallback to listTabs otherwise, Firefox 49 - 77 (bug 1618691). 82 | log.debug('Falling back to listTabs because getRoot failed', err); 83 | } 84 | 85 | try { 86 | const response = await this.client.request('listTabs'); 87 | // addonsActor was added to listTabs in Firefox 49 (bug 1273183). 88 | if (response.addonsActor == null) { 89 | log.debug( 90 | 'listTabs returned a falsey addonsActor: ' + 91 | `${JSON.stringify(response)}`, 92 | ); 93 | return Promise.reject( 94 | new RemoteTempInstallNotSupported( 95 | 'This is an older version of Firefox that does not provide an ' + 96 | 'add-ons actor for remote installation. Try Firefox 49 or ' + 97 | 'higher.', 98 | ), 99 | ); 100 | } 101 | return response.addonsActor; 102 | } catch (err) { 103 | log.debug('listTabs error', err); 104 | const message = requestErrorToMessage(err); 105 | throw new WebExtError(`Remote Firefox: listTabs() error: ${message}`); 106 | } 107 | } 108 | 109 | async installTemporaryAddon(addonPath, openDevTools) { 110 | const addonsActor = await this.getAddonsActor(); 111 | 112 | try { 113 | const response = await this.client.request({ 114 | to: addonsActor, 115 | type: 'installTemporaryAddon', 116 | addonPath, 117 | openDevTools, 118 | }); 119 | log.debug(`installTemporaryAddon: ${JSON.stringify(response)}`); 120 | log.info(`Installed ${addonPath} as a temporary add-on`); 121 | return response; 122 | } catch (err) { 123 | const message = requestErrorToMessage(err); 124 | throw new WebExtError(`installTemporaryAddon: Error: ${message}`); 125 | } 126 | } 127 | 128 | async getInstalledAddon(addonId) { 129 | try { 130 | const response = await this.client.request('listAddons'); 131 | for (const addon of response.addons) { 132 | if (addon.id === addonId) { 133 | return addon; 134 | } 135 | } 136 | log.debug( 137 | `Remote Firefox has these addons: ${response.addons.map((a) => a.id)}`, 138 | ); 139 | return Promise.reject( 140 | new WebExtError( 141 | 'The remote Firefox does not have your extension installed', 142 | ), 143 | ); 144 | } catch (err) { 145 | const message = requestErrorToMessage(err); 146 | throw new WebExtError(`Remote Firefox: listAddons() error: ${message}`); 147 | } 148 | } 149 | 150 | async checkForAddonReloading(addon) { 151 | if (this.checkedForAddonReloading) { 152 | // We only need to check once if reload() is supported. 153 | return addon; 154 | } else { 155 | const response = await this.addonRequest(addon, 'requestTypes'); 156 | 157 | if (response.requestTypes.indexOf('reload') === -1) { 158 | const supportedRequestTypes = JSON.stringify(response.requestTypes); 159 | log.debug(`Remote Firefox only supports: ${supportedRequestTypes}`); 160 | throw new UsageError( 161 | 'This Firefox version does not support add-on reloading. ' + 162 | 'Re-run with --no-reload', 163 | ); 164 | } else { 165 | this.checkedForAddonReloading = true; 166 | return addon; 167 | } 168 | } 169 | } 170 | 171 | async reloadAddon(addonId) { 172 | const addon = await this.getInstalledAddon(addonId); 173 | await this.checkForAddonReloading(addon); 174 | await this.addonRequest(addon, 'reload'); 175 | process.stdout.write( 176 | `\rLast extension reload: ${new Date().toTimeString()}`, 177 | ); 178 | log.debug('\n'); 179 | } 180 | } 181 | 182 | // Connect types and implementation 183 | 184 | export async function connect( 185 | port, 186 | { connectToFirefox = defaultFirefoxConnector } = {}, 187 | ) { 188 | log.debug(`Connecting to Firefox on port ${port}`); 189 | const client = await connectToFirefox(port); 190 | log.debug(`Connected to the remote Firefox debugger on port ${port}`); 191 | return new RemoteFirefox(client); 192 | } 193 | 194 | // ConnectWithMaxRetries types and implementation 195 | 196 | export async function connectWithMaxRetries( 197 | // A max of 250 will try connecting for 30 seconds. 198 | { maxRetries = 250, retryInterval = 120, port }, 199 | { connectToFirefox = connect } = {}, 200 | ) { 201 | async function establishConnection() { 202 | var lastError; 203 | 204 | for (let retries = 0; retries <= maxRetries; retries++) { 205 | try { 206 | return await connectToFirefox(port); 207 | } catch (error) { 208 | if (isErrorWithCode('ECONNREFUSED', error)) { 209 | // Wait for `retryInterval` ms. 210 | await new Promise((resolve) => { 211 | setTimeout(resolve, retryInterval); 212 | }); 213 | 214 | lastError = error; 215 | log.debug( 216 | `Retrying Firefox (${retries}); connection error: ${error}`, 217 | ); 218 | } else { 219 | log.error(error.stack); 220 | throw error; 221 | } 222 | } 223 | } 224 | 225 | log.debug('Connect to Firefox debugger: too many retries'); 226 | throw lastError; 227 | } 228 | 229 | log.debug('Connecting to the remote Firefox debugger'); 230 | return establishConnection(); 231 | } 232 | 233 | export function findFreeTcpPort() { 234 | return new Promise((resolve) => { 235 | const srv = net.createServer(); 236 | srv.listen(0, '127.0.0.1', () => { 237 | const freeTcpPort = srv.address().port; 238 | srv.close(() => resolve(freeTcpPort)); 239 | }); 240 | }); 241 | } 242 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { main } from './program.js'; 2 | import cmd from './cmd/index.js'; 3 | 4 | // This only exposes main and cmd, while util/logger and util/adb are defined as 5 | // separate additional exports in the package.json. 6 | export default { main, cmd }; 7 | -------------------------------------------------------------------------------- /src/util/artifacts.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | 3 | import { UsageError, isErrorWithCode } from '../errors.js'; 4 | import { createLogger } from './logger.js'; 5 | 6 | const log = createLogger(import.meta.url); 7 | 8 | const defaultAsyncFsAccess = fs.access.bind(fs); 9 | 10 | export async function prepareArtifactsDir( 11 | artifactsDir, 12 | { 13 | asyncMkdirp = (dirPath) => fs.mkdir(dirPath, { recursive: true }), 14 | asyncFsAccess = defaultAsyncFsAccess, 15 | } = {}, 16 | ) { 17 | try { 18 | const stats = await fs.stat(artifactsDir); 19 | if (!stats.isDirectory()) { 20 | throw new UsageError( 21 | `--artifacts-dir="${artifactsDir}" exists but it is not a directory.`, 22 | ); 23 | } 24 | // If the artifactsDir already exists, check that we have the write permissions on it. 25 | try { 26 | await asyncFsAccess(artifactsDir, fs.constants.W_OK); 27 | } catch (accessErr) { 28 | if (isErrorWithCode('EACCES', accessErr)) { 29 | throw new UsageError( 30 | `--artifacts-dir="${artifactsDir}" exists but the user lacks ` + 31 | 'permissions on it.', 32 | ); 33 | } else { 34 | throw accessErr; 35 | } 36 | } 37 | } catch (error) { 38 | if (isErrorWithCode('EACCES', error)) { 39 | // Handle errors when the artifactsDir cannot be accessed. 40 | throw new UsageError( 41 | `Cannot access --artifacts-dir="${artifactsDir}" because the user ` + 42 | `lacks permissions: ${error}`, 43 | ); 44 | } else if (isErrorWithCode('ENOENT', error)) { 45 | // Create the artifact dir if it doesn't exist yet. 46 | try { 47 | log.debug(`Creating artifacts directory: ${artifactsDir}`); 48 | await asyncMkdirp(artifactsDir); 49 | } catch (mkdirErr) { 50 | if (isErrorWithCode('EACCES', mkdirErr)) { 51 | // Handle errors when the artifactsDir cannot be created for lack of permissions. 52 | throw new UsageError( 53 | `Cannot create --artifacts-dir="${artifactsDir}" because the ` + 54 | `user lacks permissions: ${mkdirErr}`, 55 | ); 56 | } else { 57 | throw mkdirErr; 58 | } 59 | } 60 | } else { 61 | throw error; 62 | } 63 | } 64 | 65 | return artifactsDir; 66 | } 67 | -------------------------------------------------------------------------------- /src/util/desktop-notifier.js: -------------------------------------------------------------------------------- 1 | import defaultNotifier from 'node-notifier'; 2 | 3 | import { createLogger } from './logger.js'; 4 | 5 | const defaultLog = createLogger(import.meta.url); 6 | 7 | export function showDesktopNotification( 8 | { title, message, icon }, 9 | { notifier = defaultNotifier, log = defaultLog } = {}, 10 | ) { 11 | return new Promise((resolve, reject) => { 12 | notifier.notify({ title, message, icon }, (err, res) => { 13 | if (err) { 14 | log.debug( 15 | `Desktop notifier error: ${err.message},` + ` response: ${res}`, 16 | ); 17 | reject(err); 18 | } else { 19 | resolve(); 20 | } 21 | }); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/util/file-exists.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | 3 | import { isErrorWithCode } from '../errors.js'; 4 | 5 | /* 6 | * Resolves true if the path is a readable file. 7 | * 8 | * Usage: 9 | * 10 | * const exists = await fileExists(filePath); 11 | * if (exists) { 12 | * // ... 13 | * } 14 | * 15 | * */ 16 | export default async function fileExists( 17 | path, 18 | { fileIsReadable = (f) => fs.access(f, fs.constants.R_OK) } = {}, 19 | ) { 20 | try { 21 | await fileIsReadable(path); 22 | const stat = await fs.stat(path); 23 | return stat.isFile(); 24 | } catch (error) { 25 | if (isErrorWithCode(['EACCES', 'ENOENT'], error)) { 26 | return false; 27 | } 28 | throw error; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/util/file-filter.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import multimatch from 'multimatch'; 4 | 5 | import { createLogger } from './logger.js'; 6 | 7 | const log = createLogger(import.meta.url); 8 | 9 | // check if target is a sub directory of src 10 | export const isSubPath = (src, target) => { 11 | const relate = path.relative(src, target); 12 | // same dir 13 | if (!relate) { 14 | return false; 15 | } 16 | if (relate === '..') { 17 | return false; 18 | } 19 | return !relate.startsWith(`..${path.sep}`); 20 | }; 21 | 22 | // FileFilter types and implementation. 23 | 24 | /* 25 | * Allows or ignores files. 26 | */ 27 | export class FileFilter { 28 | filesToIgnore; 29 | sourceDir; 30 | 31 | constructor({ 32 | baseIgnoredPatterns = [ 33 | '**/*.xpi', 34 | '**/*.zip', 35 | '**/.*', // any hidden file and folder 36 | '**/.*/**/*', // and the content inside hidden folder 37 | '**/node_modules', 38 | '**/node_modules/**/*', 39 | ], 40 | ignoreFiles = [], 41 | sourceDir, 42 | artifactsDir, 43 | } = {}) { 44 | sourceDir = path.resolve(sourceDir); 45 | 46 | this.filesToIgnore = []; 47 | this.sourceDir = sourceDir; 48 | 49 | this.addToIgnoreList(baseIgnoredPatterns); 50 | if (ignoreFiles) { 51 | this.addToIgnoreList(ignoreFiles); 52 | } 53 | if (artifactsDir && isSubPath(sourceDir, artifactsDir)) { 54 | artifactsDir = path.resolve(artifactsDir); 55 | log.debug( 56 | `Ignoring artifacts directory "${artifactsDir}" ` + 57 | 'and all its subdirectories', 58 | ); 59 | this.addToIgnoreList([artifactsDir, path.join(artifactsDir, '**', '*')]); 60 | } 61 | } 62 | 63 | /** 64 | * Resolve relative path to absolute path with sourceDir. 65 | */ 66 | resolveWithSourceDir(file) { 67 | const resolvedPath = path.resolve(this.sourceDir, file); 68 | log.debug( 69 | `Resolved path ${file} with sourceDir ${this.sourceDir} ` + 70 | `to ${resolvedPath}`, 71 | ); 72 | return resolvedPath; 73 | } 74 | 75 | /** 76 | * Insert more files into filesToIgnore array. 77 | */ 78 | addToIgnoreList(files) { 79 | for (const file of files) { 80 | if (file.charAt(0) === '!') { 81 | const resolvedFile = this.resolveWithSourceDir(file.substr(1)); 82 | this.filesToIgnore.push(`!${resolvedFile}`); 83 | } else { 84 | this.filesToIgnore.push(this.resolveWithSourceDir(file)); 85 | } 86 | } 87 | } 88 | 89 | /* 90 | * Returns true if the file is wanted. 91 | * 92 | * If filePath does not start with a slash, it will be treated as a path 93 | * relative to sourceDir when matching it against all configured 94 | * ignore-patterns. 95 | * 96 | * Example: this is called by zipdir as wantFile(filePath) for each 97 | * file in the folder that is being archived. 98 | */ 99 | wantFile(filePath) { 100 | const resolvedPath = this.resolveWithSourceDir(filePath); 101 | const matches = multimatch(resolvedPath, this.filesToIgnore); 102 | if (matches.length > 0) { 103 | log.debug(`FileFilter: ignoring file ${resolvedPath}`); 104 | return false; 105 | } 106 | return true; 107 | } 108 | } 109 | 110 | // a helper function to make mocking easier 111 | 112 | export const createFileFilter = (params) => new FileFilter(params); 113 | -------------------------------------------------------------------------------- /src/util/is-directory.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | 3 | import { onlyErrorsWithCode } from '../errors.js'; 4 | 5 | /* 6 | * Resolves true if the path is a readable directory. 7 | * 8 | * Usage: 9 | * 10 | * isDirectory('/some/path') 11 | * .then((dirExists) => { 12 | * // dirExists will be true or false. 13 | * }); 14 | * 15 | * */ 16 | export default function isDirectory(path) { 17 | return fs 18 | .stat(path) 19 | .then((stats) => stats.isDirectory()) 20 | .catch( 21 | onlyErrorsWithCode(['ENOENT', 'ENOTDIR'], () => { 22 | return false; 23 | }), 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/util/logger.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url'; 2 | 3 | import pino, { levels as logLevels } from 'pino'; 4 | 5 | export class ConsoleStream { 6 | verbose; 7 | isCapturing; 8 | capturedMessages; 9 | 10 | constructor({ verbose = false } = {}) { 11 | this.verbose = verbose; 12 | this.isCapturing = false; 13 | this.capturedMessages = []; 14 | } 15 | 16 | format({ name, msg, level }) { 17 | const prefix = this.verbose ? `[${name}][${logLevels.labels[level]}] ` : ''; 18 | return `${prefix}${msg}\n`; 19 | } 20 | 21 | makeVerbose() { 22 | this.verbose = true; 23 | } 24 | 25 | write(jsonString, { localProcess = process } = {}) { 26 | const packet = JSON.parse(jsonString); 27 | const thisLevel = this.verbose 28 | ? logLevels.values.trace 29 | : logLevels.values.info; 30 | if (packet.level >= thisLevel) { 31 | const msg = this.format(packet); 32 | if (this.isCapturing) { 33 | this.capturedMessages.push(msg); 34 | } else if (packet.level > logLevels.values.info) { 35 | localProcess.stderr.write(msg); 36 | } else { 37 | localProcess.stdout.write(msg); 38 | } 39 | } 40 | } 41 | 42 | startCapturing() { 43 | this.isCapturing = true; 44 | } 45 | 46 | stopCapturing() { 47 | this.isCapturing = false; 48 | this.capturedMessages = []; 49 | } 50 | 51 | flushCapturedLogs({ localProcess = process } = {}) { 52 | for (const msg of this.capturedMessages) { 53 | localProcess.stdout.write(msg); 54 | } 55 | this.capturedMessages = []; 56 | } 57 | } 58 | 59 | export const consoleStream = new ConsoleStream(); 60 | 61 | // createLogger types and implementation. 62 | 63 | export function createLogger(moduleURL, { createPinoLog = pino } = {}) { 64 | return createPinoLog( 65 | { 66 | // Strip the leading src/ from file names (which is in all file names) to 67 | // make the name less redundant. 68 | name: moduleURL 69 | ? fileURLToPath(moduleURL).replace(/^src\//, '') 70 | : 'unknown-module', 71 | // Capture all log levels and let the stream filter them. 72 | level: logLevels.values.trace, 73 | }, 74 | consoleStream, 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/util/manifest.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs/promises'; 3 | 4 | import parseJSON from 'parse-json'; 5 | import stripBom from 'strip-bom'; 6 | import stripJsonComments from 'strip-json-comments'; 7 | 8 | import { InvalidManifest } from '../errors.js'; 9 | import { createLogger } from './logger.js'; 10 | 11 | const log = createLogger(import.meta.url); 12 | 13 | // getValidatedManifest helper types and implementation 14 | 15 | export default async function getValidatedManifest(sourceDir) { 16 | const manifestFile = path.join(sourceDir, 'manifest.json'); 17 | log.debug(`Validating manifest at ${manifestFile}`); 18 | 19 | let manifestContents; 20 | 21 | try { 22 | manifestContents = await fs.readFile(manifestFile, { encoding: 'utf-8' }); 23 | } catch (error) { 24 | throw new InvalidManifest( 25 | `Could not read manifest.json file at ${manifestFile}: ${error}`, 26 | ); 27 | } 28 | 29 | manifestContents = stripBom(manifestContents); 30 | 31 | let manifestData; 32 | 33 | try { 34 | manifestData = parseJSON(stripJsonComments(manifestContents)); 35 | } catch (error) { 36 | throw new InvalidManifest( 37 | `Error parsing manifest.json file at ${manifestFile}: ${error}`, 38 | ); 39 | } 40 | 41 | const errors = []; 42 | // This is just some basic validation of what web-ext needs, not 43 | // what Firefox will need to run the extension. 44 | // TODO: integrate with the addons-linter for actual validation. 45 | if (!manifestData.name) { 46 | errors.push('missing "name" property'); 47 | } 48 | if (!manifestData.version) { 49 | errors.push('missing "version" property'); 50 | } 51 | 52 | if (manifestData.applications && !manifestData.applications.gecko) { 53 | // Since the applications property only applies to gecko, make 54 | // sure 'gecko' exists when 'applications' is defined. This should 55 | // make introspection of gecko properties easier. 56 | errors.push('missing "applications.gecko" property'); 57 | } 58 | 59 | if (errors.length) { 60 | throw new InvalidManifest( 61 | `Manifest at ${manifestFile} is invalid: ${errors.join('; ')}`, 62 | ); 63 | } 64 | 65 | return manifestData; 66 | } 67 | 68 | export function getManifestId(manifestData) { 69 | const manifestApps = [ 70 | manifestData.browser_specific_settings, 71 | manifestData.applications, 72 | ]; 73 | for (const apps of manifestApps) { 74 | // If both bss and applications contain a defined gecko property, 75 | // we prefer bss even if the id property isn't available. 76 | // This match what Firefox does in this particular scenario, see 77 | // https://searchfox.org/mozilla-central/rev/828f2319c0195d7f561ed35533aef6fe183e68e3/toolkit/mozapps/extensions/internal/XPIInstall.jsm#470-474,488 78 | if (apps?.gecko) { 79 | return apps.gecko.id; 80 | } 81 | } 82 | 83 | return undefined; 84 | } 85 | -------------------------------------------------------------------------------- /src/util/promisify.js: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | 3 | export const promisifyCustom = promisify.custom; 4 | 5 | /* 6 | * A small promisify helper to make it easier to customize a 7 | * function promisified (using the 'util' module available in 8 | * nodejs >= 8) to resolve to an array of results: 9 | * 10 | * import {promisify} from 'util'; 11 | * import {multiArgsPromisedFn} from '../util/promisify'; 12 | * 13 | * aCallbackBasedFn[promisify.custom] = multiArgsPromisedFn(tmp.dir); 14 | * ... 15 | */ 16 | export function multiArgsPromisedFn(fn) { 17 | return (...callerArgs) => { 18 | return new Promise((resolve, reject) => { 19 | fn(...callerArgs, (err, ...rest) => { 20 | if (err) { 21 | reject(err); 22 | } else { 23 | resolve(rest); 24 | } 25 | }); 26 | }); 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/util/stdin.js: -------------------------------------------------------------------------------- 1 | export function isTTY(stream) { 2 | return stream.isTTY; 3 | } 4 | 5 | export function setRawMode(stream, rawMode) { 6 | stream.setRawMode(rawMode); 7 | } 8 | -------------------------------------------------------------------------------- /src/util/temp-dir.js: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | 3 | import tmp from 'tmp'; 4 | 5 | import { createLogger } from './logger.js'; 6 | import { multiArgsPromisedFn, promisifyCustom } from './promisify.js'; 7 | 8 | const log = createLogger(import.meta.url); 9 | 10 | tmp.dir[promisifyCustom] = multiArgsPromisedFn(tmp.dir); 11 | 12 | const createTempDir = promisify(tmp.dir); 13 | 14 | /* 15 | * Work with a self-destructing temporary directory in a promise chain. 16 | * 17 | * The directory will be destroyed when the promise chain is finished 18 | * (whether there was an error or not). 19 | * 20 | * Usage: 21 | * 22 | * withTempDir( 23 | * (tmpDir) => 24 | * doSomething(tmpDir.path()) 25 | * .then(...) 26 | * ); 27 | * 28 | */ 29 | export function withTempDir(makePromise) { 30 | const tmpDir = new TempDir(); 31 | return tmpDir 32 | .create() 33 | .then(() => { 34 | return makePromise(tmpDir); 35 | }) 36 | .catch(tmpDir.errorHandler()) 37 | .then(tmpDir.successHandler()); 38 | } 39 | 40 | /* 41 | * Work with a self-destructing temporary directory object. 42 | * 43 | * It is safer to use withTempDir() instead but if you know 44 | * what you're doing you can use it directly like: 45 | * 46 | * let tmpDir = new TempDir(); 47 | * tmpDir.create() 48 | * .then(() => { 49 | * // work with tmpDir.path() 50 | * }) 51 | * .catch(tmpDir.errorHandler()) 52 | * .then(tmpDir.successHandler()); 53 | * 54 | */ 55 | export class TempDir { 56 | _path; 57 | _removeTempDir; 58 | 59 | constructor() { 60 | this._path = undefined; 61 | this._removeTempDir = undefined; 62 | } 63 | 64 | /* 65 | * Returns a promise that is fulfilled when the temp directory has 66 | * been created. 67 | */ 68 | create() { 69 | return createTempDir({ 70 | prefix: 'tmp-web-ext-', 71 | // This allows us to remove a non-empty tmp dir. 72 | unsafeCleanup: true, 73 | }).then(([tmpPath, removeTempDir]) => { 74 | this._path = tmpPath; 75 | this._removeTempDir = () => 76 | new Promise((resolve, reject) => { 77 | // `removeTempDir` parameter is a `next` callback which 78 | // is called once the dir has been removed. 79 | const next = (err) => (err ? reject(err) : resolve()); 80 | removeTempDir(next); 81 | }); 82 | log.debug(`Created temporary directory: ${this.path()}`); 83 | return this; 84 | }); 85 | } 86 | 87 | /* 88 | * Get the absolute path of the temp directory. 89 | */ 90 | path() { 91 | if (!this._path) { 92 | throw new Error('You cannot access path() before calling create()'); 93 | } 94 | return this._path; 95 | } 96 | 97 | /* 98 | * Returns a callback that will catch an error, remove 99 | * the temporary directory, and throw the error. 100 | * 101 | * This is intended for use in a promise like 102 | * Promise().catch(tmp.errorHandler()) 103 | */ 104 | errorHandler() { 105 | return async (error) => { 106 | await this.remove(); 107 | throw error; 108 | }; 109 | } 110 | 111 | /* 112 | * Returns a callback that will remove the temporary direcotry. 113 | * 114 | * This is intended for use in a promise like 115 | * Promise().then(tmp.successHandler()) 116 | */ 117 | successHandler() { 118 | return async (promiseResult) => { 119 | await this.remove(); 120 | return promiseResult; 121 | }; 122 | } 123 | 124 | /* 125 | * Remove the temp directory. 126 | */ 127 | remove() { 128 | if (!this._removeTempDir) { 129 | return; 130 | } 131 | log.debug(`Removing temporary directory: ${this.path()}`); 132 | return this._removeTempDir && this._removeTempDir(); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/util/updates.js: -------------------------------------------------------------------------------- 1 | import defaultUpdateNotifier from 'update-notifier'; 2 | 3 | export function checkForUpdates({ 4 | version, 5 | updateNotifier = defaultUpdateNotifier, 6 | }) { 7 | const pkg = { name: 'web-ext', version }; 8 | 9 | updateNotifier({ 10 | pkg, 11 | updateCheckInterval: 1000 * 60 * 60 * 24 * 3, // 3 days, 12 | }).notify(); 13 | } 14 | -------------------------------------------------------------------------------- /src/watcher.js: -------------------------------------------------------------------------------- 1 | import { existsSync, lstatSync } from 'fs'; 2 | 3 | import Watchpack from 'watchpack'; 4 | import debounce from 'debounce'; 5 | 6 | import { UsageError } from './errors.js'; 7 | import { createLogger } from './util/logger.js'; 8 | 9 | const log = createLogger(import.meta.url); 10 | 11 | // onSourceChange types and implementation 12 | 13 | export default function onSourceChange({ 14 | sourceDir, 15 | watchFile, 16 | watchIgnored, 17 | artifactsDir, 18 | onChange, 19 | shouldWatchFile, 20 | debounceTime = 500, 21 | }) { 22 | // When running on Windows, transform the ignored paths and globs 23 | // as Watchpack does translate the changed files path internally 24 | // (See https://github.com/webpack/watchpack/blob/v2.1.1/lib/DirectoryWatcher.js#L99-L103). 25 | const ignored = 26 | watchIgnored && process.platform === 'win32' 27 | ? watchIgnored.map((it) => it.replace(/\\/g, '/')) 28 | : watchIgnored; 29 | 30 | // TODO: For network disks, we would need to add {poll: true}. 31 | const watcher = ignored ? new Watchpack({ ignored }) : new Watchpack(); 32 | 33 | // Allow multiple files to be changed before reloading the extension 34 | const executeImmediately = false; 35 | onChange = debounce(onChange, debounceTime, executeImmediately); 36 | 37 | watcher.on('change', (filePath) => { 38 | proxyFileChanges({ artifactsDir, onChange, filePath, shouldWatchFile }); 39 | }); 40 | 41 | log.debug( 42 | `Watching ${watchFile ? watchFile.join(',') : sourceDir} for changes`, 43 | ); 44 | 45 | const watchedDirs = []; 46 | const watchedFiles = []; 47 | 48 | if (watchFile) { 49 | for (const filePath of watchFile) { 50 | if (existsSync(filePath) && !lstatSync(filePath).isFile()) { 51 | throw new UsageError( 52 | 'Invalid --watch-file value: ' + `"${filePath}" is not a file.`, 53 | ); 54 | } 55 | 56 | watchedFiles.push(filePath); 57 | } 58 | } else { 59 | watchedDirs.push(sourceDir); 60 | } 61 | 62 | watcher.watch({ 63 | files: watchedFiles, 64 | directories: watchedDirs, 65 | missing: [], 66 | startTime: Date.now(), 67 | }); 68 | 69 | // TODO: support interrupting the watcher on Windows. 70 | // https://github.com/mozilla/web-ext/issues/225 71 | process.on('SIGINT', () => watcher.close()); 72 | return watcher; 73 | } 74 | 75 | // proxyFileChanges types and implementation. 76 | 77 | export function proxyFileChanges({ 78 | artifactsDir, 79 | onChange, 80 | filePath, 81 | shouldWatchFile, 82 | }) { 83 | if (filePath.indexOf(artifactsDir) === 0 || !shouldWatchFile(filePath)) { 84 | log.debug(`Ignoring change to: ${filePath}`); 85 | } else { 86 | log.debug(`Changed: ${filePath}`); 87 | log.debug(`Last change detection: ${new Date().toTimeString()}`); 88 | onChange(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | }, 5 | "rules": { 6 | "import/no-extraneous-dependencies": ["error", { 7 | // Allow dev-dependencies in this directory. 8 | "devDependencies": true 9 | }], 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/fixtures/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": 0, 4 | "import/extensions": 0, 5 | "import/no-extraneous-dependencies": 0, 6 | "max-len": 0, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/dashed-locale/_locales/en_US/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "extension with dashed locale" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/fixtures/dashed-locale/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "__MSG_extensionName__", 4 | "version": "1.0", 5 | "default_locale": "en-US" 6 | } 7 | -------------------------------------------------------------------------------- /tests/fixtures/minimal-localizable-web-ext/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "Name of the extension", 4 | "description": "This is an example of a minimal extension that does nothing" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/fixtures/minimal-localizable-web-ext/background-script.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | console.log('background script loaded'); 4 | -------------------------------------------------------------------------------- /tests/fixtures/minimal-localizable-web-ext/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "__MSG_extensionName__", 4 | "description": "__MSG_extensionDescription__", 5 | "version": "1.0", 6 | "default_locale": "en" 7 | } 8 | -------------------------------------------------------------------------------- /tests/fixtures/minimal-web-ext/.private-file1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/web-ext/fd555674174102e4b8a93442d27795b6aa69f691/tests/fixtures/minimal-web-ext/.private-file1.txt -------------------------------------------------------------------------------- /tests/fixtures/minimal-web-ext/background-script.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | console.log('background script loaded'); 4 | -------------------------------------------------------------------------------- /tests/fixtures/minimal-web-ext/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Minimal Extension", 4 | "description": "This is an example of a minimal extension that does nothing", 5 | "version": "1.0", 6 | "applications": { 7 | "gecko": { 8 | "id": "minimal-example@web-ext-test-suite" 9 | } 10 | }, 11 | "browser_action": {}, 12 | "permissions": [ 13 | "tabs" 14 | ], 15 | "background": { 16 | "scripts": ["background-script.js"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/fixtures/minimal-web-ext/node_modules/pkg1/file1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/web-ext/fd555674174102e4b8a93442d27795b6aa69f691/tests/fixtures/minimal-web-ext/node_modules/pkg1/file1.txt -------------------------------------------------------------------------------- /tests/fixtures/minimal-web-ext/node_modules/pkg2/file2.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/web-ext/fd555674174102e4b8a93442d27795b6aa69f691/tests/fixtures/minimal-web-ext/node_modules/pkg2/file2.txt -------------------------------------------------------------------------------- /tests/fixtures/minimal_extension-1.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozilla/web-ext/fd555674174102e4b8a93442d27795b6aa69f691/tests/fixtures/minimal_extension-1.0.zip -------------------------------------------------------------------------------- /tests/fixtures/slashed-name/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Name w/o slash \\o/", 3 | "description": "ends with.zip", 4 | "version": "1", 5 | "manifest_version": 2 6 | } 7 | -------------------------------------------------------------------------------- /tests/fixtures/webext-as-library/helpers.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const path = require('path'); 3 | 4 | async function testModuleExports(webExt) { 5 | assert.deepEqual(Object.keys(webExt).sort(), ['cmd', 'main'].sort()); 6 | assert.equal(typeof webExt.cmd.run, 'function'); 7 | 8 | } 9 | 10 | async function testModuleExportedUtils() { 11 | assertImportedADB({expectLoaded: false}); 12 | const utilADB = await import('web-ext/util/adb'); // eslint-disable-line import/no-unresolved 13 | assert.equal(typeof utilADB.listADBDevices, 'function'); 14 | assert.equal(typeof utilADB.listADBFirefoxAPKs, 'function'); 15 | assertImportedADB({expectLoaded: true}); 16 | 17 | const utilLogger = await import('web-ext/util/logger'); // eslint-disable-line import/no-unresolved 18 | assert.equal(typeof utilLogger.createLogger, 'function'); 19 | assert.equal(typeof utilLogger.ConsoleStream?.constructor, 'function'); 20 | assert.ok(utilLogger.consoleStream instanceof utilLogger.ConsoleStream); 21 | 22 | const utilSubmitAddon = await import('web-ext/util/submit-addon'); // eslint-disable-line import/no-unresolved 23 | assert.equal(typeof utilSubmitAddon.signAddon, 'function'); 24 | assert.equal(typeof utilSubmitAddon.default, 'function'); 25 | assert.equal(typeof utilSubmitAddon.JwtApiAuth, 'function'); 26 | } 27 | 28 | function assertImportedADB({expectLoaded}) { 29 | const adbPathString = path.join('@devicefarmer', 'adbkit'); 30 | const hasAdbDeps = Object.keys(require.cache).filter( 31 | (filePath) => filePath.includes(adbPathString) 32 | ).length > 0; 33 | 34 | const msg = expectLoaded 35 | ? 'adb module should have been loaded' 36 | : 'adb module should not be loaded yet'; 37 | 38 | assert.equal(hasAdbDeps, expectLoaded, msg); 39 | } 40 | 41 | module.exports = { 42 | testModuleExports, 43 | testModuleExportedUtils, 44 | }; 45 | -------------------------------------------------------------------------------- /tests/fixtures/webext-as-library/test-import.mjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import webExt from 'web-ext'; 3 | 4 | // eslint-disable-next-line import/extensions 5 | import helpers from './helpers.js'; 6 | 7 | const {testModuleExports, testModuleExportedUtils} = helpers; 8 | 9 | await testModuleExports(webExt); 10 | await testModuleExportedUtils(); 11 | -------------------------------------------------------------------------------- /tests/fixtures/webext-as-library/test-require.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const {testModuleExports, testModuleExportedUtils} = require('./helpers.js'); 4 | 5 | 6 | (async () => { 7 | // Trying to require web-ext as a CommonJS module is not supported anymore 8 | // and it should be throwing the expected ERR_REQUIRE_ESM error. 9 | assert.throws( 10 | () => require('web-ext'), 11 | { 12 | name: 'Error', 13 | code: 'ERR_REQUIRE_ESM', 14 | } 15 | ); 16 | 17 | // But it should still be possible to import it in a CommonJS module 18 | // using a dynamic import. 19 | const {cmd, main} = await import('web-ext'); // eslint-disable-line import/no-unresolved 20 | 21 | await testModuleExports({cmd, main}); 22 | await testModuleExportedUtils(); 23 | })(); 24 | -------------------------------------------------------------------------------- /tests/functional/common.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { spawn } from 'child_process'; 3 | import { promisify } from 'util'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | import copyDir from 'copy-dir'; 7 | import prettyjson from 'prettyjson'; 8 | 9 | import * as tmpDirUtils from '../../src/util/temp-dir.js'; 10 | 11 | export const withTempDir = tmpDirUtils.withTempDir; 12 | 13 | export const functionalTestsDir = path.resolve( 14 | path.dirname(fileURLToPath(import.meta.url || '')), 15 | ); 16 | export const projectDir = path.join(functionalTestsDir, '..', '..'); 17 | export const webExt = process.env.TEST_WEB_EXT_BIN 18 | ? path.resolve(process.env.TEST_WEB_EXT_BIN) 19 | : path.join(projectDir, 'bin', 'web-ext'); 20 | export const fixturesDir = path.join(functionalTestsDir, '..', 'fixtures'); 21 | export const minimalAddonPath = path.join(fixturesDir, 'minimal-web-ext'); 22 | export const fixturesUseAsLibrary = path.join(fixturesDir, 'webext-as-library'); 23 | export const fakeFirefoxPath = path.join( 24 | functionalTestsDir, 25 | process.platform === 'win32' 26 | ? 'fake-firefox-binary.bat' 27 | : 'fake-firefox-binary.js', 28 | ); 29 | export const fakeServerPath = path.join( 30 | functionalTestsDir, 31 | 'fake-amo-server.js', 32 | ); 33 | 34 | // withTempAddonDir helper 35 | 36 | const copyDirAsPromised = promisify(copyDir); 37 | 38 | export function withTempAddonDir({ addonPath }, makePromise) { 39 | return withTempDir((tmpDir) => { 40 | const tempAddonDir = path.join(tmpDir.path(), 'tmp-addon-dir'); 41 | return copyDirAsPromised(addonPath, tempAddonDir).then(() => { 42 | process.chdir(tmpDir.path()); 43 | 44 | return makePromise(tempAddonDir, tmpDir.path()) 45 | .then(() => process.chdir(projectDir)) 46 | .catch((err) => { 47 | process.chdir(projectDir); 48 | throw err; 49 | }); 50 | }); 51 | }); 52 | } 53 | 54 | // reportCommandErrors helper 55 | 56 | export function reportCommandErrors(obj, msg) { 57 | const errorMessage = msg || 'Unexpected web-ext functional test result'; 58 | const formattedErrorData = prettyjson.render(obj); 59 | const error = new Error(`${errorMessage}: \n${formattedErrorData}`); 60 | /* eslint-disable no-console */ 61 | 62 | // Make the error diagnostic info easier to read. 63 | console.error('This test failed. Please check the log below to debug.'); 64 | /* eslint-enable no-console */ 65 | 66 | // Make sure the test fails and error diagnostic fully reported in the failure. 67 | throw error; 68 | } 69 | 70 | // execWebExt helper 71 | 72 | export function execWebExt(argv, spawnOptions) { 73 | if (spawnOptions.env) { 74 | spawnOptions.env = { 75 | // Propagate the current environment when redefining it from the `spawnOptions` 76 | // otherwise it may trigger unexpected failures due to missing variables that 77 | // may be expected (e.g. #2444 was failing only on Windows because 78 | // @pnpm/npm-conf, a transitive dependencies for update-notifier, was expecting 79 | // process.env.APPDATA to be defined when running on Windows). 80 | ...process.env, 81 | ...spawnOptions.env, 82 | }; 83 | } 84 | const spawnedProcess = spawn( 85 | process.execPath, 86 | [webExt, ...argv], 87 | spawnOptions, 88 | ); 89 | 90 | const waitForExit = new Promise((resolve) => { 91 | let errorData = ''; 92 | let outputData = ''; 93 | 94 | spawnedProcess.stderr.on('data', (data) => (errorData += data)); 95 | spawnedProcess.stdout.on('data', (data) => (outputData += data)); 96 | 97 | spawnedProcess.on('close', (exitCode) => { 98 | resolve({ 99 | exitCode, 100 | stderr: errorData, 101 | stdout: outputData, 102 | }); 103 | }); 104 | }); 105 | 106 | return { argv, waitForExit, spawnedProcess }; 107 | } 108 | -------------------------------------------------------------------------------- /tests/functional/fake-amo-server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Fake AMO signing server: 4 | // - http://addons-server.readthedocs.io/en/latest/topics/api/signing.html 5 | 6 | import http from 'http'; 7 | 8 | const FAKE_REPLIES = [ 9 | // Upload responses, see https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#upload-detail-object 10 | 11 | // Upload response with processed false (which is expected to polling 12 | // until the processed becomes true). 13 | { 14 | uuid: '{fake-upload-uuid}', 15 | channel: 'unlisted', 16 | processed: false, 17 | }, 18 | // Upload response with processed false (which is expected to stop polling 19 | // the upload status and move to fetch the version details). 20 | { 21 | uuid: '{fake-upload-uuid}', 22 | channel: 'unlisted', 23 | processed: true, 24 | submitted: false, 25 | url: 'http://localhost:8989/fake-validation-result', 26 | valid: true, 27 | validation: {}, 28 | version: { id: 123 }, 29 | }, 30 | 31 | // Version responses, see https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#version-detail. 32 | 33 | // Version response with file.status unreviewed (which is expected to polling 34 | // until the file.status becomes public). 35 | { 36 | id: 123, 37 | guid: 'fake-guid', 38 | channel: 'unlisted', 39 | edit_url: 'http://localhost:8989/fake-devhub-url', 40 | reviewed: true, 41 | file: { 42 | id: 456, 43 | hash: '29bd832510553001a178ecf1e74111ee65cf5286d22215008be2c23757a4e4fd', 44 | status: 'unreviewed', 45 | url: 'http://localhost:8989/fake-download-url.xpi', 46 | }, 47 | version: { id: 123 }, 48 | }, 49 | // Version response with file.status public (which is expected to stop the 50 | // polling waiting for a signed xpi to download). 51 | { 52 | id: 123, 53 | guid: 'fake-guid', 54 | channel: 'unlisted', 55 | edit_url: 'http://localhost:8989/fake-devhub-url', 56 | reviewed: true, 57 | file: { 58 | id: 456, 59 | hash: '29bd832510553001a178ecf1e74111ee65cf5286d22215008be2c23757a4e4fd', 60 | status: 'public', 61 | url: 'http://localhost:8989/fake-download-url.xpi', 62 | }, 63 | version: { id: 123 }, 64 | }, 65 | 66 | // Final fake xpi download response. 67 | {}, 68 | ]; 69 | 70 | var replyIndex = 0; 71 | 72 | http 73 | .createServer(function (req, res) { 74 | const reply = FAKE_REPLIES[replyIndex++]; 75 | 76 | if (reply) { 77 | req.on('data', function () { 78 | // Ignore request body. 79 | }); 80 | // Wait for the transfer of the request body to finish before sending a response. 81 | // Otherwise the client could experience an EPIPE error: 82 | // https://github.com/nodejs/node/issues/12339 83 | req.once('end', function () { 84 | res.writeHead(200, { 'content-type': 'application/json' }); 85 | res.write(JSON.stringify(reply)); 86 | res.end(); 87 | }); 88 | } else { 89 | process.exit(1); 90 | } 91 | }) 92 | .listen(8989, 'localhost', () => { 93 | process.stdout.write('listening'); 94 | process.stdout.uncork(); 95 | }); 96 | -------------------------------------------------------------------------------- /tests/functional/fake-firefox-binary.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | REM Spawn this file as an Firefox executable on Windows. 3 | node "%~dp0fake-firefox-binary.js" %* 4 | -------------------------------------------------------------------------------- /tests/functional/fake-firefox-binary.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // Load the TCP Library 3 | import net from 'net'; 4 | 5 | const REPLY_INITIAL = { from: 'root' }; 6 | const REQUEST_ACTORS = { to: 'root', type: 'getRoot' }; 7 | const REPLY_ACTORS = { from: 'root', addonsActor: 'fakeAddonsActor' }; 8 | const REQUEST_INSTALL_ADDON = { 9 | to: 'fakeAddonsActor', 10 | type: 'installTemporaryAddon', 11 | addonPath: process.env.addonPath, 12 | openDevTools: false, // Introduced in Firefox 106 (Bug 1787409 / Bug 1789245) 13 | }; 14 | const REPLY_INSTALL_ADDON = { 15 | from: 'fakeAddonsActor', 16 | addon: { 17 | id: 'fake-generated-id', 18 | }, 19 | }; 20 | 21 | function toRDP(msg) { 22 | const data = JSON.stringify(msg); 23 | return [data.length, ':', data].join(''); 24 | } 25 | 26 | // Get the debugger server port from the cli arguments 27 | function getPortFromArgs() { 28 | const index = process.argv.indexOf('-start-debugger-server'); 29 | if (index === -1) { 30 | throw new Error('The -start-debugger-server parameter is not present.'); 31 | } 32 | const port = process.argv[index + 1]; 33 | if (isNaN(port)) { 34 | throw new Error(`Value of port must be a number. ${port} is not a number.`); 35 | } 36 | 37 | return parseInt(port, 10); 38 | } 39 | net 40 | .createServer(function (socket) { 41 | socket.on('data', function (data) { 42 | if (String(data) === toRDP(REQUEST_ACTORS)) { 43 | socket.write(toRDP(REPLY_ACTORS)); 44 | } else if (String(data) === toRDP(REQUEST_INSTALL_ADDON)) { 45 | socket.write(toRDP(REPLY_INSTALL_ADDON)); 46 | 47 | process.stderr.write(`${process.env.EXPECTED_MESSAGE}\n`); 48 | 49 | process.exit(0); 50 | } else { 51 | process.stderr.write( 52 | `Fake Firefox received an unexpected message: ${String(data)}\n`, 53 | ); 54 | process.exit(1); 55 | } 56 | }); 57 | 58 | socket.write(toRDP(REPLY_INITIAL)); 59 | }) 60 | .listen(getPortFromArgs(), '127.0.0.1'); 61 | -------------------------------------------------------------------------------- /tests/functional/test.cli.build.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { assert } from 'chai'; 3 | 4 | import { 5 | minimalAddonPath, 6 | withTempAddonDir, 7 | execWebExt, 8 | reportCommandErrors, 9 | } from './common.js'; 10 | 11 | describe('web-ext build', () => { 12 | it('should accept: --source-dir SRCDIR', () => 13 | withTempAddonDir({ addonPath: minimalAddonPath }, (srcDir, tmpDir) => { 14 | const argv = ['build', '--source-dir', srcDir, '--verbose']; 15 | const cmd = execWebExt(argv, { cwd: tmpDir }); 16 | 17 | return cmd.waitForExit.then(({ exitCode, stdout, stderr }) => { 18 | if (exitCode !== 0) { 19 | reportCommandErrors({ 20 | argv, 21 | exitCode, 22 | stdout, 23 | stderr, 24 | }); 25 | } 26 | }); 27 | })); 28 | 29 | it('throws an error on multiple -n', () => 30 | withTempAddonDir({ addonPath: minimalAddonPath }, (srcDir, tmpDir) => { 31 | const argv = ['build', '-n', 'foo', '-n', 'bar']; 32 | const cmd = execWebExt(argv, { cwd: tmpDir }); 33 | return cmd.waitForExit.then(({ exitCode, stderr }) => { 34 | assert.notEqual(exitCode, 0); 35 | assert.match(stderr, /Multiple --filename\/-n option are not allowed/); 36 | }); 37 | })); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/functional/test.cli.dump-config.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { assert } from 'chai'; 3 | import parseJSON from 'parse-json'; 4 | 5 | import { withTempDir, execWebExt, reportCommandErrors } from './common.js'; 6 | 7 | describe('web-ext dump-config', () => { 8 | it('should emit valid JSON string to stdout', () => 9 | withTempDir((tmpDir) => { 10 | const argv = ['dump-config']; 11 | const cmd = execWebExt(argv, { cwd: tmpDir.path() }); 12 | 13 | return cmd.waitForExit.then(({ exitCode, stdout, stderr }) => { 14 | if (exitCode !== 0) { 15 | reportCommandErrors({ 16 | argv, 17 | exitCode, 18 | stdout, 19 | stderr, 20 | }); 21 | return; 22 | } 23 | const parsedConfigData = parseJSON(stdout); 24 | assert.equal(parsedConfigData.sourceDir, tmpDir.path()); 25 | }); 26 | })); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/functional/test.cli.lint.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | 3 | import { 4 | minimalAddonPath, 5 | withTempAddonDir, 6 | execWebExt, 7 | reportCommandErrors, 8 | } from './common.js'; 9 | 10 | describe('web-ext lint', () => { 11 | it('should accept: --source-dir SRCDIR', () => 12 | withTempAddonDir({ addonPath: minimalAddonPath }, (srcDir, tmpDir) => { 13 | const argv = ['lint', '--source-dir', srcDir, '--verbose']; 14 | const cmd = execWebExt(argv, { cwd: tmpDir }); 15 | 16 | return cmd.waitForExit.then(({ exitCode, stdout, stderr }) => { 17 | if (exitCode !== 0) { 18 | reportCommandErrors({ 19 | argv, 20 | exitCode, 21 | stdout, 22 | stderr, 23 | }); 24 | } 25 | }); 26 | })); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/functional/test.cli.nocommand.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { assert } from 'chai'; 3 | 4 | import { withTempDir, execWebExt, reportCommandErrors } from './common.js'; 5 | 6 | describe('web-ext', () => { 7 | it('should accept: --help', () => 8 | withTempDir((tmpDir) => { 9 | const argv = ['--help']; 10 | const cmd = execWebExt(argv, { cwd: tmpDir.path() }); 11 | 12 | return cmd.waitForExit.then(({ exitCode, stdout, stderr }) => { 13 | if (exitCode !== 0) { 14 | reportCommandErrors({ 15 | argv, 16 | exitCode, 17 | stdout, 18 | stderr, 19 | }); 20 | } 21 | }); 22 | })); 23 | 24 | it('should hide --input from --help output', () => 25 | withTempDir(async (tmpDir) => { 26 | const cmd = execWebExt(['--help'], { cwd: tmpDir.path() }); 27 | const { stdout } = await cmd.waitForExit; 28 | assert.equal( 29 | stdout.includes('--input'), 30 | false, 31 | 'help does not include --input', 32 | ); 33 | assert.equal( 34 | stdout.includes('--no-input'), 35 | true, 36 | 'help does include --no-input', 37 | ); 38 | })); 39 | }); 40 | -------------------------------------------------------------------------------- /tests/functional/test.cli.run.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { writeFileSync } from 'fs'; 3 | 4 | import { describe, it } from 'mocha'; 5 | import { assert } from 'chai'; 6 | 7 | import { 8 | minimalAddonPath, 9 | fakeFirefoxPath, 10 | withTempAddonDir, 11 | execWebExt, 12 | reportCommandErrors, 13 | } from './common.js'; 14 | 15 | const EXPECTED_MESSAGE = 'Fake Firefox binary executed correctly.'; 16 | 17 | describe('web-ext run', () => { 18 | it( 19 | 'accepts: --no-reload --watch-file --watch-files --source-dir ' + 20 | 'SRCDIR --firefox FXPATH --watch-ignored', 21 | () => 22 | withTempAddonDir({ addonPath: minimalAddonPath }, (srcDir) => { 23 | const watchedFile = path.join(srcDir, 'watchedFile.txt'); 24 | const watchedFilesArr = ['watchedFile1', 'watchedFile2'].map((file) => 25 | path.join(srcDir, file), 26 | ); 27 | const watchIgnoredArr = ['ignoredFile1.txt', 'ignoredFile2.txt'].map( 28 | (file) => path.join(srcDir, file), 29 | ); 30 | const watchIgnoredFile = path.join(srcDir, 'ignoredFile3.txt'); 31 | 32 | writeFileSync(watchedFile, ''); 33 | watchedFilesArr.forEach((file) => writeFileSync(file, '')); 34 | watchIgnoredArr.forEach((file) => writeFileSync(file, '')); 35 | writeFileSync(watchIgnoredFile, ''); 36 | 37 | const argv = [ 38 | 'run', 39 | '--verbose', 40 | '--no-reload', 41 | '--source-dir', 42 | srcDir, 43 | '--watch-file', 44 | watchedFile, 45 | '--watch-files', 46 | ...watchedFilesArr, 47 | '--firefox', 48 | fakeFirefoxPath, 49 | '--watch-ignored', 50 | ...watchIgnoredArr, 51 | '--watch-ignored', 52 | watchIgnoredFile, 53 | ]; 54 | const spawnOptions = { 55 | env: { 56 | EXPECTED_MESSAGE, 57 | addonPath: srcDir, 58 | // Add an environment var unrelated to the executed command to 59 | // ensure we do clear the environment vars from them before 60 | // yargs is validation the detected cli and env options. 61 | // (See #793). 62 | WEB_EXT_API_KEY: 'fake-api-key', 63 | // Also include an environment var that misses the '_' separator 64 | // between envPrefix and option name. 65 | WEB_EXTAPI_SECRET: 'fake-secret', 66 | }, 67 | }; 68 | 69 | const cmd = execWebExt(argv, spawnOptions); 70 | 71 | return cmd.waitForExit.then(({ exitCode, stdout, stderr }) => { 72 | if (stdout.indexOf(EXPECTED_MESSAGE) < 0) { 73 | reportCommandErrors( 74 | { 75 | argv, 76 | exitCode, 77 | stdout, 78 | stderr, 79 | }, 80 | 'The fake Firefox binary has not been executed', 81 | ); 82 | } else if (exitCode !== 0) { 83 | reportCommandErrors({ 84 | argv, 85 | exitCode, 86 | stdout, 87 | stderr, 88 | }); 89 | } 90 | }); 91 | }), 92 | ); 93 | 94 | it('should not accept: --watch-file <directory>', () => 95 | withTempAddonDir({ addonPath: minimalAddonPath }, (srcDir) => { 96 | const argv = [ 97 | 'run', 98 | '--verbose', 99 | '--source-dir', 100 | srcDir, 101 | '--watch-file', 102 | srcDir, 103 | '--firefox', 104 | fakeFirefoxPath, 105 | ]; 106 | 107 | const spawnOptions = { 108 | env: { 109 | addonPath: srcDir, 110 | }, 111 | }; 112 | 113 | return execWebExt(argv, spawnOptions).waitForExit.then(({ stderr }) => { 114 | assert.match(stderr, /Invalid --watch-file value: .+ is not a file./); 115 | }); 116 | })); 117 | 118 | it('should not accept: --target INVALIDTARGET', async () => { 119 | const argv = [ 120 | 'run', 121 | '--target', 122 | 'firefox-desktop', 123 | '--target', 124 | 'firefox-android', 125 | '--target', 126 | 'chromium', 127 | '--target', 128 | 'not-supported', 129 | ]; 130 | 131 | return execWebExt(argv, {}).waitForExit.then(({ exitCode, stderr }) => { 132 | assert.notEqual(exitCode, 0); 133 | assert.match(stderr, /Invalid values/); 134 | assert.match(stderr, /Given: "not-supported"/); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /tests/functional/test.cli.sign.js: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import path from 'path'; 3 | import { writeFileSync } from 'fs'; 4 | 5 | import { assert } from 'chai'; 6 | import { describe, it, beforeEach, afterEach } from 'mocha'; 7 | 8 | import { 9 | minimalAddonPath, 10 | fakeServerPath, 11 | withTempAddonDir, 12 | execWebExt, 13 | reportCommandErrors, 14 | } from './common.js'; 15 | 16 | // Put this as "web-ext-config.js" in the current directory, and replace 17 | // "FAKEAPIKEY" and "FAKEAPISECRET" with the actual values to enable 18 | // "web-ext sign" without passing those values via the CLI parameters. 19 | const GOOD_EXAMPLE_OF_WEB_EXT_CONFIG_JS = ` 20 | module.exports = { 21 | sign: { 22 | apiKey: "FAKEAPIKEY", 23 | apiSecret: "FAKEAPISECRET", 24 | }, 25 | }; 26 | `; 27 | 28 | // Do NOT use this to specify the API key and secret. It won't work. 29 | const BAD_EXAMPLE_OF_WEB_EXT_CONFIG_JS = ` 30 | module.exports = { 31 | // Bad config: those should be under the "sign" key. 32 | apiKey: "FAKEAPIKEY", 33 | apiSecret: "FAKEAPISECRET", 34 | }; 35 | `; 36 | 37 | describe('web-ext sign', () => { 38 | let fakeServerProcess; 39 | 40 | beforeEach(() => { 41 | return new Promise((resolve, reject) => { 42 | const newProcess = spawn(process.execPath, [fakeServerPath]); 43 | newProcess.stdout.on('data', resolve); 44 | newProcess.stderr.on('data', reject); 45 | fakeServerProcess = newProcess; 46 | }); 47 | }); 48 | 49 | afterEach(() => { 50 | if (fakeServerProcess) { 51 | fakeServerProcess.kill(); 52 | fakeServerProcess = null; 53 | } 54 | }); 55 | 56 | it('should accept: --source-dir SRCDIR --amo-base-url URL', () => 57 | withTempAddonDir({ addonPath: minimalAddonPath }, (srcDir, tmpDir) => { 58 | const argv = [ 59 | 'sign', 60 | '--verbose', 61 | '--channel', 62 | 'listed', 63 | '--amo-base-url', 64 | 'http://localhost:8989/fake/api/v5', 65 | '--api-key', 66 | 'FAKEAPIKEY', 67 | '--api-secret', 68 | 'FAKEAPISECRET', 69 | '--source-dir', 70 | srcDir, 71 | ]; 72 | const cmd = execWebExt(argv, { cwd: tmpDir }); 73 | 74 | return cmd.waitForExit.then(({ exitCode, stdout, stderr }) => { 75 | if (exitCode !== 0) { 76 | reportCommandErrors({ 77 | argv, 78 | exitCode, 79 | stdout, 80 | stderr, 81 | }); 82 | } 83 | }); 84 | })); 85 | 86 | it('should use config file if required parameters are not in the arguments', () => 87 | withTempAddonDir({ addonPath: minimalAddonPath }, (srcDir, tmpDir) => { 88 | writeFileSync( 89 | path.join(tmpDir, 'web-ext-config.js'), 90 | GOOD_EXAMPLE_OF_WEB_EXT_CONFIG_JS, 91 | ); 92 | 93 | writeFileSync( 94 | path.join(tmpDir, 'package.json'), 95 | JSON.stringify({ 96 | webExt: { 97 | sign: { 98 | amoBaseUrl: 'http://localhost:8989/fake/api/v5', 99 | channel: 'listed', 100 | }, 101 | sourceDir: srcDir, 102 | }, 103 | }), 104 | ); 105 | 106 | const argv = ['sign', '--verbose']; 107 | const cmd = execWebExt(argv, { cwd: tmpDir }); 108 | 109 | return cmd.waitForExit.then(({ exitCode, stdout, stderr }) => { 110 | if (exitCode !== 0) { 111 | reportCommandErrors({ 112 | argv, 113 | exitCode, 114 | stdout, 115 | stderr, 116 | }); 117 | } 118 | }); 119 | })); 120 | 121 | it('should show an error message if the api-key is not set in the config', () => 122 | withTempAddonDir({ addonPath: minimalAddonPath }, (srcDir, tmpDir) => { 123 | const configFilePath = path.join(tmpDir, 'web-ext-config.js'); 124 | writeFileSync(configFilePath, BAD_EXAMPLE_OF_WEB_EXT_CONFIG_JS); 125 | const argv = [ 126 | 'sign', 127 | '--verbose', 128 | '--no-config-discovery', 129 | '-c', 130 | configFilePath, 131 | ]; 132 | const cmd = execWebExt(argv, { cwd: tmpDir }); 133 | 134 | return cmd.waitForExit.then(({ exitCode, stderr }) => { 135 | assert.notEqual(exitCode, 0); 136 | assert.match( 137 | stderr, 138 | /web-ext-config.js specified an unknown option: "apiKey"/, 139 | ); 140 | }); 141 | })); 142 | 143 | it('should show an error message if the api-key cannot be found', () => 144 | withTempAddonDir({ addonPath: minimalAddonPath }, (srcDir, tmpDir) => { 145 | const argv = ['sign', '--verbose', '--no-config-discovery']; 146 | const cmd = execWebExt(argv, { cwd: tmpDir }); 147 | 148 | return cmd.waitForExit.then(({ exitCode, stderr }) => { 149 | assert.notEqual(exitCode, 0); 150 | assert.match(stderr, /Missing required arguments: api-key, api-secret/); 151 | }); 152 | })); 153 | }); 154 | -------------------------------------------------------------------------------- /tests/functional/test.lib.imports.js: -------------------------------------------------------------------------------- 1 | import { execFileSync } from 'child_process'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | import { describe, it, before } from 'mocha'; 6 | import shell from 'shelljs'; 7 | 8 | import { withTempDir, fixturesUseAsLibrary } from './common.js'; 9 | 10 | const npm = shell.which('npm')?.toString(); 11 | const node = shell.which('node')?.toString(); 12 | 13 | const dirname = path.dirname(fileURLToPath(import.meta.url || '')); 14 | const packageDir = path.resolve(path.join(dirname, '..', '..')); 15 | 16 | describe('web-ext imported as a library', () => { 17 | before(function () { 18 | // Only run this test in automation unless manually activated 19 | // using the CI environment variable, it is going to re-install 20 | // all the web-ext production dependencies and so it is time 21 | // consuming. 22 | if (!process.env.CI) { 23 | this.skip(); 24 | } 25 | }); 26 | 27 | it('can be imported as an ESM module', async () => { 28 | await withTempDir(async (tmpDir) => { 29 | execFileSync(npm, ['install', packageDir], { 30 | cwd: tmpDir.path(), 31 | stdio: 'inherit', 32 | }); 33 | shell.cp('-rf', `${fixturesUseAsLibrary}/*`, tmpDir.path()); 34 | execFileSync(node, ['--experimental-modules', 'test-import.mjs'], { 35 | cwd: tmpDir.path(), 36 | }); 37 | }); 38 | }); 39 | 40 | it('can be imported as a CommonJS module', async () => { 41 | await withTempDir(async (tmpDir) => { 42 | execFileSync(npm, ['install', packageDir], { cwd: tmpDir.path() }); 43 | shell.cp('-rf', `${fixturesUseAsLibrary}/*`, tmpDir.path()); 44 | execFileSync(node, ['--experimental-modules', 'test-require.js'], { 45 | cwd: tmpDir.path(), 46 | }); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/functional/test.typo.run.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { assert } from 'chai'; 3 | 4 | import { execWebExt } from './common.js'; 5 | 6 | describe('web-ext', () => { 7 | it('recommends matching command', async () => { 8 | const argv = ['buld']; 9 | 10 | return execWebExt(argv, {}).waitForExit.then(({ exitCode, stderr }) => { 11 | assert.notEqual(exitCode, 0); 12 | assert.match(stderr, /Did you mean build/); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | import { use as chaiUse } from 'chai'; 2 | import chaiAsPromised from 'chai-as-promised'; 3 | 4 | // Enable chai-as-promised plugin. 5 | chaiUse(chaiAsPromised); 6 | -------------------------------------------------------------------------------- /tests/unit/test-cmd/test.docs.js: -------------------------------------------------------------------------------- 1 | import { it, describe } from 'mocha'; 2 | import * as sinon from 'sinon'; 3 | import { assert } from 'chai'; 4 | 5 | import defaultDocsCommand, { url } from '../../../src/cmd/docs.js'; 6 | 7 | describe('docs', () => { 8 | it('passes the correct url to docs', async () => { 9 | const openUrl = sinon.spy(async () => {}); 10 | await defaultDocsCommand({}, { openUrl }); 11 | sinon.assert.calledWith(openUrl, url); 12 | }); 13 | 14 | it('throws an error when open fails', async () => { 15 | const openUrl = sinon.spy(async () => { 16 | throw new Error('pretends this is an error from open()'); 17 | }); 18 | await assert.isRejected( 19 | defaultDocsCommand({}, { openUrl }), 20 | /error from open()/, 21 | ); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /tests/unit/test-cmd/test.lint.js: -------------------------------------------------------------------------------- 1 | import { it, describe } from 'mocha'; 2 | import { assert } from 'chai'; 3 | import * as sinon from 'sinon'; 4 | 5 | import defaultLintCommand from '../../../src/cmd/lint.js'; 6 | 7 | describe('lint', () => { 8 | function setUp({ createLinter, createFileFilter } = {}) { 9 | const lintResult = '<lint.run() result placeholder>'; 10 | const runLinter = sinon.spy(() => Promise.resolve(lintResult)); 11 | if (!createLinter) { 12 | createLinter = sinon.spy(() => { 13 | return { run: runLinter }; 14 | }); 15 | } 16 | return { 17 | lintResult, 18 | createLinter, 19 | runLinter, 20 | lint: (params = {}, options = {}) => { 21 | const mergedArgs = { sourceDir: '/fake/source/dir', ...params }; 22 | const mergedOpts = { 23 | createLinter, 24 | createFileFilter, 25 | ...options, 26 | }; 27 | return defaultLintCommand(mergedArgs, mergedOpts); 28 | }, 29 | }; 30 | } 31 | 32 | it('creates and runs a linter', () => { 33 | const { lint, createLinter, runLinter, lintResult } = setUp(); 34 | return lint().then((actualLintResult) => { 35 | assert.equal(actualLintResult, lintResult); 36 | sinon.assert.called(createLinter); 37 | sinon.assert.called(runLinter); 38 | }); 39 | }); 40 | 41 | it('fails when the linter fails', async () => { 42 | const createLinter = () => { 43 | return { 44 | run: () => Promise.reject(new Error('some error from the linter')), 45 | }; 46 | }; 47 | const { lint } = setUp({ createLinter }); 48 | 49 | await assert.isRejected(lint(), /error from the linter/); 50 | }); 51 | 52 | it('runs as a binary', () => { 53 | const { lint, createLinter } = setUp(); 54 | return lint().then(() => { 55 | sinon.assert.calledWithMatch(createLinter, { runAsBinary: true }); 56 | }); 57 | }); 58 | 59 | it('sets runAsBinary according shouldExitProgram option', () => { 60 | const { lint, createLinter } = setUp(); 61 | return lint({}, { shouldExitProgram: false }).then(() => { 62 | sinon.assert.calledWithMatch(createLinter, { runAsBinary: false }); 63 | }); 64 | }); 65 | 66 | it('passes sourceDir to the linter', () => { 67 | const { lint, createLinter } = setUp(); 68 | return lint({ sourceDir: '/some/path' }).then(() => { 69 | const config = createLinter.firstCall.args[0].config; 70 | assert.equal(config._[0], '/some/path'); 71 | }); 72 | }); 73 | 74 | it('passes warningsAsErrors to the linter', () => { 75 | const { lint, createLinter } = setUp(); 76 | return lint({ warningsAsErrors: true }).then(() => { 77 | sinon.assert.calledWithMatch(createLinter, { 78 | config: { 79 | warningsAsErrors: true, 80 | }, 81 | }); 82 | }); 83 | }); 84 | 85 | it('passes warningsAsErrors undefined to the linter', () => { 86 | const { lint, createLinter } = setUp(); 87 | return lint().then(() => { 88 | sinon.assert.calledWithMatch(createLinter, { 89 | config: { 90 | warningsAsErrors: undefined, 91 | }, 92 | }); 93 | }); 94 | }); 95 | 96 | it('configures the linter when verbose', () => { 97 | const { lint, createLinter } = setUp(); 98 | return lint({ verbose: true }).then(() => { 99 | sinon.assert.calledWithMatch(createLinter, { 100 | config: { 101 | logLevel: 'debug', 102 | stack: true, 103 | }, 104 | }); 105 | }); 106 | }); 107 | 108 | it('configures the linter when not verbose', () => { 109 | const { lint, createLinter } = setUp(); 110 | return lint({ verbose: false }).then(() => { 111 | sinon.assert.calledWithMatch(createLinter, { 112 | config: { 113 | logLevel: 'fatal', 114 | stack: false, 115 | }, 116 | }); 117 | }); 118 | }); 119 | 120 | it('passes through linter configuration', () => { 121 | const { lint, createLinter } = setUp(); 122 | return lint({ 123 | pretty: true, 124 | privileged: true, 125 | metadata: true, 126 | output: 'json', 127 | boring: true, 128 | selfHosted: true, 129 | }).then(() => { 130 | sinon.assert.calledWithMatch(createLinter, { 131 | config: { 132 | pretty: true, 133 | privileged: true, 134 | metadata: true, 135 | output: 'json', 136 | boring: true, 137 | selfHosted: true, 138 | minManifestVersion: 2, 139 | maxManifestVersion: 3, 140 | }, 141 | }); 142 | }); 143 | }); 144 | 145 | it('configures a lint command with the expected fileFilter', () => { 146 | const fileFilter = { wantFile: sinon.spy(() => true) }; 147 | const createFileFilter = sinon.spy(() => fileFilter); 148 | const { lint, createLinter } = setUp({ createFileFilter }); 149 | const params = { 150 | sourceDir: '.', 151 | artifactsDir: 'artifacts', 152 | ignoreFiles: ['file1', '**/file2'], 153 | }; 154 | return lint(params).then(() => { 155 | sinon.assert.calledWith(createFileFilter, params); 156 | 157 | assert.ok(createLinter.called); 158 | const { shouldScanFile } = createLinter.firstCall.args[0].config; 159 | shouldScanFile('path/to/file'); 160 | sinon.assert.calledWith(fileFilter.wantFile, 'path/to/file'); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /tests/unit/test-firefox/test.preferences.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { assert } from 'chai'; 3 | 4 | import { WebExtError, UsageError } from '../../../src/errors.js'; 5 | import { 6 | getPrefs, 7 | coerceCLICustomPreference, 8 | nonOverridablePreferences, 9 | } from '../../../src/firefox/preferences.js'; 10 | 11 | describe('firefox/preferences', () => { 12 | describe('getPrefs', () => { 13 | it('gets Firefox prefs with some defaults', () => { 14 | const prefs = getPrefs(); 15 | // This is a commonly shared pref. 16 | assert.equal(prefs['devtools.debugger.remote-enabled'], true); 17 | // This is a Firefox only pref. 18 | assert.equal(prefs['devtools.chrome.enabled'], true); 19 | // This is a Firefox only pref that we set to prevent Firefox 20 | // to open the privacy policy info page on every "web-ext run". 21 | assert.equal(prefs['datareporting.policy.firstRunURL'], ''); 22 | }); 23 | 24 | it('gets Fennec prefs with some defaults', () => { 25 | const prefs = getPrefs('fennec'); 26 | // This is a commonly shared pref. 27 | assert.equal(prefs['devtools.debugger.remote-enabled'], true); 28 | // This is a Fennec only pref. 29 | assert.equal(prefs['browser.console.showInPanel'], true); 30 | }); 31 | 32 | it('throws an error for unsupported apps', () => { 33 | assert.throws( 34 | () => getPrefs('thunderbird'), 35 | WebExtError, 36 | /Unsupported application: thunderbird/, 37 | ); 38 | }); 39 | }); 40 | 41 | describe('coerceCLICustomPreference', () => { 42 | it('converts a single --pref cli option from string to object', () => { 43 | const prefs = coerceCLICustomPreference(['valid.preference=true']); 44 | assert.isObject(prefs); 45 | assert.equal(prefs['valid.preference'], true); 46 | }); 47 | 48 | it('converts array of --pref cli option values into object', () => { 49 | const prefs = coerceCLICustomPreference([ 50 | 'valid.preference=true', 51 | 'valid.preference2=false', 52 | ]); 53 | assert.isObject(prefs); 54 | assert.equal(prefs['valid.preference'], true); 55 | assert.equal(prefs['valid.preference2'], false); 56 | }); 57 | 58 | it('converts boolean values', () => { 59 | const prefs = coerceCLICustomPreference(['valid.preference=true']); 60 | assert.equal(prefs['valid.preference'], true); 61 | }); 62 | 63 | it('converts number values', () => { 64 | const prefs = coerceCLICustomPreference(['valid.preference=455']); 65 | assert.equal(prefs['valid.preference'], 455); 66 | }); 67 | 68 | it('converts float values', () => { 69 | const prefs = coerceCLICustomPreference(['valid.preference=4.55']); 70 | assert.equal(prefs['valid.preference'], '4.55'); 71 | }); 72 | 73 | it('supports string values with "=" chars', () => { 74 | const prefs = coerceCLICustomPreference([ 75 | 'valid.preference=value=withequals=chars', 76 | ]); 77 | assert.equal(prefs['valid.preference'], 'value=withequals=chars'); 78 | }); 79 | 80 | it('does not allow certain default preferences to be customized', () => { 81 | const nonChangeablePrefs = nonOverridablePreferences.map((prop) => { 82 | return (prop += '=true'); 83 | }); 84 | const prefs = coerceCLICustomPreference(nonChangeablePrefs); 85 | for (const pref of nonChangeablePrefs) { 86 | assert.isUndefined(prefs[pref], `${pref} should be undefined`); 87 | } 88 | }); 89 | 90 | it('throws an error for invalid or incomplete preferences', () => { 91 | assert.throws( 92 | () => coerceCLICustomPreference(['test.invalid.prop']), 93 | UsageError, 94 | 'Incomplete custom preference: "test.invalid.prop". ' + 95 | 'Syntax expected: "prefname=prefvalue".', 96 | ); 97 | 98 | assert.throws( 99 | () => coerceCLICustomPreference(['*&%£=true']), 100 | UsageError, 101 | 'Invalid custom preference name: *&%£', 102 | ); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /tests/unit/test-util/test.artifacts.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs/promises'; 3 | 4 | import { it, describe } from 'mocha'; 5 | import { assert } from 'chai'; 6 | import * as sinon from 'sinon'; 7 | 8 | import { onlyInstancesOf, UsageError } from '../../../src/errors.js'; 9 | import { withTempDir } from '../../../src/util/temp-dir.js'; 10 | import { prepareArtifactsDir } from '../../../src/util/artifacts.js'; 11 | import { makeSureItFails, ErrorWithCode } from '../helpers.js'; 12 | 13 | describe('prepareArtifactsDir', () => { 14 | it('creates an artifacts dir if needed', () => 15 | withTempDir((tmpDir) => { 16 | const artifactsDir = path.join(tmpDir.path(), 'build'); 17 | return prepareArtifactsDir(artifactsDir).then(() => { 18 | // This should not throw an error if created properly. 19 | return fs.stat(artifactsDir); 20 | }); 21 | })); 22 | 23 | it('ignores existing artifacts dir', () => 24 | withTempDir((tmpDir) => 25 | prepareArtifactsDir(tmpDir.path()).then(() => { 26 | // Make sure everything is still cool with this path. 27 | return fs.stat(tmpDir.path()); 28 | }), 29 | )); 30 | 31 | it('ensures the path is really a directory', () => 32 | withTempDir((tmpDir) => { 33 | const someFile = path.join(tmpDir.path(), 'some-file.txt'); 34 | return fs 35 | .writeFile(someFile, 'some content') 36 | .then(() => prepareArtifactsDir(someFile)) 37 | .then(makeSureItFails()) 38 | .catch( 39 | onlyInstancesOf(UsageError, (error) => { 40 | assert.match(error.message, /not a directory/); 41 | }), 42 | ); 43 | })); 44 | 45 | it('resolves with the artifacts dir', () => 46 | withTempDir((tmpDir) => { 47 | const artifactsDir = path.join(tmpDir.path(), 'artifacts'); 48 | return prepareArtifactsDir(artifactsDir).then((resolvedDir) => { 49 | assert.equal(resolvedDir, artifactsDir); 50 | }); 51 | })); 52 | 53 | it('throws an UsageError when it lacks permissions to stat the directory', function () { 54 | return withTempDir((tmpDir) => { 55 | if (process.platform === 'win32') { 56 | this.skip(); 57 | return; 58 | } 59 | const tmpPath = path.join(tmpDir.path(), 'build'); 60 | return fs.mkdir(tmpPath, '0622').then(() => { 61 | const artifactsDir = path.join(tmpPath, 'artifacts'); 62 | return prepareArtifactsDir(artifactsDir) 63 | .then(makeSureItFails()) 64 | .catch( 65 | onlyInstancesOf(UsageError, (error) => { 66 | assert.match(error.message, /Cannot access.*lacks permissions/); 67 | }), 68 | ); 69 | }); 70 | }); 71 | }); 72 | 73 | it('throws error when directory exists but lacks writing permissions', function () { 74 | return withTempDir((tmpDir) => { 75 | if (process.platform === 'win32') { 76 | this.skip(); 77 | return; 78 | } 79 | const artifactsDir = path.join(tmpDir.path(), 'dir-nowrite'); 80 | return fs.mkdir(artifactsDir, '0555').then(() => { 81 | return prepareArtifactsDir(artifactsDir) 82 | .then(makeSureItFails()) 83 | .catch( 84 | onlyInstancesOf(UsageError, (error) => { 85 | assert.match(error.message, /exists.*lacks permissions/); 86 | }), 87 | ); 88 | }); 89 | }); 90 | }); 91 | 92 | it('throws error when creating a folder if lacks writing permissions', function () { 93 | return withTempDir((tmpDir) => { 94 | if (process.platform === 'win32') { 95 | this.skip(); 96 | return; 97 | } 98 | const parentDir = path.join(tmpDir.path(), 'dir-nowrite'); 99 | const artifactsDir = path.join(parentDir, 'artifacts'); 100 | return fs.mkdir(parentDir, '0555').then(() => { 101 | return prepareArtifactsDir(artifactsDir) 102 | .then(makeSureItFails()) 103 | .catch( 104 | onlyInstancesOf(UsageError, (error) => { 105 | assert.match(error.message, /Cannot create.*lacks permissions/); 106 | }), 107 | ); 108 | }); 109 | }); 110 | }); 111 | 112 | it('creates the artifacts dir successfully if the parent dir does not exist', () => 113 | withTempDir((tmpDir) => { 114 | const tmpPath = path.join(tmpDir.path(), 'build', 'subdir'); 115 | return prepareArtifactsDir(tmpPath).then((resolvedDir) => { 116 | assert.equal(resolvedDir, tmpPath); 117 | }); 118 | })); 119 | 120 | it('throws error when creating a folder if there is not enough space', () => 121 | withTempDir(async (tmpDir) => { 122 | const fakeAsyncMkdirp = sinon.spy(() => 123 | Promise.reject(new ErrorWithCode('ENOSPC', 'an error')), 124 | ); 125 | const tmpPath = path.join(tmpDir.path(), 'build', 'subdir'); 126 | 127 | await assert.isRejected( 128 | prepareArtifactsDir(tmpPath, { asyncMkdirp: fakeAsyncMkdirp }), 129 | 'ENOSPC: an error', 130 | ); 131 | 132 | sinon.assert.called(fakeAsyncMkdirp); 133 | })); 134 | 135 | it('throws on unexpected errors', () => 136 | withTempDir(async (tmpDir) => { 137 | const fakeAsyncFsAccess = sinon.spy(() => 138 | Promise.reject(new Error('Unexpected fs.access error')), 139 | ); 140 | const fakeAsyncMkdirp = sinon.spy(() => 141 | Promise.reject(new Error('Unexpected mkdirp error')), 142 | ); 143 | 144 | await assert.isRejected( 145 | prepareArtifactsDir(tmpDir.path(), { 146 | asyncFsAccess: fakeAsyncFsAccess, 147 | asyncMkdirp: fakeAsyncMkdirp, 148 | }), 149 | /Unexpected fs.access error/, 150 | ); 151 | 152 | sinon.assert.called(fakeAsyncFsAccess); 153 | sinon.assert.notCalled(fakeAsyncMkdirp); 154 | })); 155 | }); 156 | -------------------------------------------------------------------------------- /tests/unit/test-util/test.desktop-notifier.js: -------------------------------------------------------------------------------- 1 | import { it, describe } from 'mocha'; 2 | import * as sinon from 'sinon'; 3 | 4 | import { showDesktopNotification } from '../../../src/util/desktop-notifier.js'; 5 | import { createLogger } from '../../../src/util/logger.js'; 6 | import { makeSureItFails } from '../helpers.js'; 7 | 8 | describe('util/desktop-notifier', () => { 9 | describe('desktopNotifications()', () => { 10 | const expectedNotification = { 11 | title: 'web-ext run: title', 12 | message: 'message', 13 | }; 14 | 15 | it('is called and creates a message with correct parameters', () => { 16 | const fakeNotifier = { 17 | notify: sinon.spy((options, callback) => callback()), 18 | }; 19 | return showDesktopNotification(expectedNotification, { 20 | notifier: fakeNotifier, 21 | }).then(() => { 22 | sinon.assert.calledWithMatch(fakeNotifier.notify, { 23 | title: 'web-ext run: title', 24 | message: 'message', 25 | }); 26 | }); 27 | }); 28 | 29 | it('logs error when notifier fails', () => { 30 | const expectedError = new Error('an error'); 31 | const fakeLog = createLogger(import.meta.url); 32 | sinon.spy(fakeLog, 'debug'); 33 | const fakeNotifier = { 34 | notify: (obj, callback) => { 35 | callback(expectedError, 'response'); 36 | }, 37 | }; 38 | 39 | return showDesktopNotification(expectedNotification, { 40 | notifier: fakeNotifier, 41 | log: fakeLog, 42 | }) 43 | .then(makeSureItFails()) 44 | .catch(() => { 45 | sinon.assert.calledWith( 46 | fakeLog.debug, 47 | `Desktop notifier error: ${expectedError.message}, ` + 48 | 'response: response', 49 | ); 50 | }); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/unit/test-util/test.file-exists.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs/promises'; 3 | 4 | import { assert } from 'chai'; 5 | import { describe, it } from 'mocha'; 6 | 7 | import fileExists from '../../../src/util/file-exists.js'; 8 | import { withTempDir } from '../../../src/util/temp-dir.js'; 9 | import { ErrorWithCode } from '../helpers.js'; 10 | 11 | describe('util/file-exists', () => { 12 | it('returns true for existing files', () => { 13 | return withTempDir(async (tmpDir) => { 14 | const someFile = path.join(tmpDir.path(), 'file.txt'); 15 | await fs.writeFile(someFile, ''); 16 | 17 | assert.equal(await fileExists(someFile), true); 18 | }); 19 | }); 20 | 21 | it('returns false for non-existent files', () => { 22 | return withTempDir(async (tmpDir) => { 23 | // This file does not exist. 24 | const someFile = path.join(tmpDir.path(), 'file.txt'); 25 | 26 | assert.equal(await fileExists(someFile), false); 27 | }); 28 | }); 29 | 30 | it('returns false for directories', () => { 31 | return withTempDir(async (tmpDir) => { 32 | assert.equal(await fileExists(tmpDir.path()), false); 33 | }); 34 | }); 35 | 36 | it('returns false for unreadable files', async () => { 37 | const exists = await fileExists('pretend/unreadable/file', { 38 | fileIsReadable: async () => { 39 | throw new ErrorWithCode('EACCES', 'permission denied'); 40 | }, 41 | }); 42 | assert.equal(exists, false); 43 | }); 44 | 45 | it('throws unexpected errors', async () => { 46 | const exists = fileExists('pretend/file', { 47 | fileIsReadable: async () => { 48 | throw new ErrorWithCode('EBUSY', 'device is busy'); 49 | }, 50 | }); 51 | 52 | await assert.isRejected(exists, 'EBUSY: device is busy'); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/unit/test-util/test.file-filter.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { assert } from 'chai'; 3 | 4 | import { FileFilter, isSubPath } from '../../../src/util/file-filter.js'; 5 | 6 | describe('util/file-filter', () => { 7 | const newFileFilter = (params = {}) => { 8 | return new FileFilter({ 9 | sourceDir: '.', 10 | ...params, 11 | }); 12 | }; 13 | 14 | describe('default', () => { 15 | const defaultFilter = newFileFilter(); 16 | 17 | it('ignores long XPI paths', () => { 18 | assert.equal(defaultFilter.wantFile('path/to/some.xpi'), false); 19 | }); 20 | 21 | it('ignores short XPI paths', () => { 22 | assert.equal(defaultFilter.wantFile('some.xpi'), false); 23 | }); 24 | 25 | it('ignores .git directories', () => { 26 | assert.equal(defaultFilter.wantFile('.git'), false); 27 | }); 28 | 29 | it('ignores nested .git directories', () => { 30 | assert.equal(defaultFilter.wantFile('path/to/.git'), false); 31 | }); 32 | 33 | it('ignores any hidden file', () => { 34 | assert.equal(defaultFilter.wantFile('.whatever'), false); 35 | }); 36 | 37 | it('ignores subdirectories within hidden folders', () => { 38 | assert.equal(defaultFilter.wantFile('.git/some/other/stuff'), false); 39 | }); 40 | 41 | it('ignores ZPI paths', () => { 42 | assert.equal(defaultFilter.wantFile('path/to/some.zip'), false); 43 | }); 44 | 45 | it('allows other files', () => { 46 | assert.equal(defaultFilter.wantFile('manifest.json'), true); 47 | }); 48 | 49 | it('ignores node_modules by default', () => { 50 | assert.equal(defaultFilter.wantFile('path/to/node_modules'), false); 51 | }); 52 | 53 | it('ignores module content within node_modules by default', () => { 54 | assert.equal( 55 | defaultFilter.wantFile('node_modules/something/file.js'), 56 | false, 57 | ); 58 | }); 59 | }); 60 | 61 | describe('options', () => { 62 | it('override the defaults with baseIgnoredPatterns', () => { 63 | const filter = newFileFilter({ 64 | baseIgnoredPatterns: ['manifest.json'], 65 | }); 66 | assert.equal(filter.wantFile('some.xpi'), true); 67 | assert.equal(filter.wantFile('manifest.json'), false); 68 | }); 69 | 70 | it('add more files to ignore with ignoreFiles', () => { 71 | const filter = newFileFilter({ 72 | ignoreFiles: ['*.log'], 73 | }); 74 | assert.equal(filter.wantFile('some.xpi'), false); 75 | assert.equal(filter.wantFile('some.log'), false); 76 | }); 77 | 78 | it('ignore artifactsDir and its content', () => { 79 | const filter = newFileFilter({ 80 | artifactsDir: 'artifacts', 81 | }); 82 | assert.equal(filter.wantFile('artifacts'), false); 83 | assert.equal(filter.wantFile('artifacts/some.js'), false); 84 | }); 85 | 86 | it('does not ignore an artifactsDir outside of sourceDir', () => { 87 | const filter = newFileFilter({ 88 | artifactsDir: '.', 89 | sourceDir: 'dist', 90 | }); 91 | assert.equal(filter.wantFile('file'), true); 92 | assert.equal(filter.wantFile('dist/file'), true); 93 | }); 94 | 95 | it('resolve relative path', () => { 96 | const filter = newFileFilter({ 97 | sourceDir: '/src', 98 | artifactsDir: 'artifacts', 99 | ignoreFiles: [ 100 | 'ignore-dir/', 101 | 'some.js', 102 | '**/some.log', 103 | 'ignore/dir/content/**/*', 104 | ], 105 | }); 106 | assert.equal(filter.wantFile('/src/artifacts'), true); 107 | assert.equal(filter.wantFile('/src/ignore-dir'), false); 108 | assert.equal(filter.wantFile('/src/ignore-dir/some.css'), true); 109 | assert.equal(filter.wantFile('/src/some.js'), false); 110 | assert.equal(filter.wantFile('/src/some.log'), false); 111 | assert.equal(filter.wantFile('/src/other/some.js'), true); 112 | assert.equal(filter.wantFile('/src/other/some.log'), false); 113 | assert.equal(filter.wantFile('/src/ignore/dir/content'), true); 114 | assert.equal(filter.wantFile('/src/ignore/dir/content/file.js'), false); 115 | // This file is not ignored because it's not relative to /src: 116 | assert.equal(filter.wantFile('/some.js'), true); 117 | }); 118 | }); 119 | 120 | describe('isSubPath', () => { 121 | it('test if target is a sub directory of src', () => { 122 | assert.equal(isSubPath('dist', '.'), false); 123 | assert.equal(isSubPath('.', 'artifacts'), true); 124 | assert.equal(isSubPath('.', '.'), false); 125 | assert.equal(isSubPath('/src/dist', '/src'), false); 126 | assert.equal(isSubPath('/src', '/src/artifacts'), true); 127 | assert.equal(isSubPath('/src', '/src'), false); 128 | assert.equal(isSubPath('/firstroot', '/secondroot'), false); 129 | assert.equal(isSubPath('/src', '/src/.dir'), true); 130 | assert.equal(isSubPath('/src', '/src/..dir'), true); 131 | }); 132 | }); 133 | 134 | describe('negation', () => { 135 | const filter = newFileFilter({ 136 | sourceDir: '/src', 137 | ignoreFiles: ['!node_modules/libdweb/src/**'], 138 | }); 139 | 140 | it('ignore paths not captured by negation', () => { 141 | assert.equal(filter.wantFile('/src/node_modules/lib/foo.js'), false); 142 | assert.equal(filter.wantFile('/src/node_modules/lib'), false); 143 | assert.equal(filter.wantFile('/src/node_modules/what.js'), false); 144 | assert.equal(filter.wantFile('/src/node_modules/libdweb/what.js'), false); 145 | assert.equal(filter.wantFile('/src/node_modules/libdweb/src.js'), false); 146 | assert.equal(filter.wantFile('/src/node_modules/libdweb/src'), false); 147 | }); 148 | 149 | it('includes paths captured by negation', () => { 150 | assert.equal( 151 | filter.wantFile('/src/node_modules/libdweb/src/lib.js'), 152 | true, 153 | ); 154 | assert.equal( 155 | filter.wantFile('/src/node_modules/libdweb/src/sub/lib.js'), 156 | true, 157 | ); 158 | assert.equal( 159 | filter.wantFile('/src/node_modules/libdweb/src/node_modules/lib.js'), 160 | true, 161 | ); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /tests/unit/test-util/test.is-directory.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs/promises'; 3 | 4 | import { assert } from 'chai'; 5 | import { describe, it } from 'mocha'; 6 | 7 | import isDirectory from '../../../src/util/is-directory.js'; 8 | import { withTempDir } from '../../../src/util/temp-dir.js'; 9 | 10 | describe('util.isDirectory', () => { 11 | it('resolves true for a directory', () => 12 | withTempDir((tmpDir) => { 13 | return isDirectory(tmpDir.path()).then((dirExists) => { 14 | assert.equal(dirExists, true); 15 | }); 16 | })); 17 | 18 | it('resolves false for non-existent paths', () => { 19 | return isDirectory('/dev/null/not-a-real-path-at-all').then((dirExists) => { 20 | assert.equal(dirExists, false); 21 | }); 22 | }); 23 | 24 | it('resolves false for non-directory paths', () => 25 | withTempDir((tmpDir) => { 26 | const filePath = path.join(tmpDir.path(), 'some.txt'); 27 | return fs 28 | .writeFile(filePath, 'some text') 29 | .then(() => isDirectory(filePath)) 30 | .then((dirExists) => { 31 | assert.equal(dirExists, false); 32 | }); 33 | })); 34 | 35 | it('resolves false for incomplete directory paths', () => 36 | withTempDir((tmpDir) => { 37 | return isDirectory(path.join(tmpDir.path(), 'missing-leaf')).then( 38 | (dirExists) => { 39 | assert.equal(dirExists, false); 40 | }, 41 | ); 42 | })); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/unit/test-util/test.logger.js: -------------------------------------------------------------------------------- 1 | import { Writable } from 'stream'; 2 | import { pathToFileURL } from 'url'; 3 | 4 | import { levels as logLevels } from 'pino'; 5 | import * as sinon from 'sinon'; 6 | import { it, describe } from 'mocha'; 7 | import { assert } from 'chai'; 8 | 9 | import { createLogger, ConsoleStream } from '../../../src/util/logger.js'; 10 | 11 | describe('logger', () => { 12 | describe('createLogger', () => { 13 | it('makes file names less redundant', () => { 14 | const createPinoLog = sinon.spy(() => {}); 15 | const expectedName = 16 | process.platform === 'win32' 17 | ? 'C:\\src\\some-file.js' 18 | : '/src/some-file.js'; 19 | createLogger(pathToFileURL(expectedName).href, { createPinoLog }); 20 | sinon.assert.calledWithMatch(createPinoLog, { name: expectedName }); 21 | }); 22 | }); 23 | 24 | describe('ConsoleStream', () => { 25 | function packet(overrides) { 26 | return JSON.stringify({ 27 | name: 'some name', 28 | msg: 'some messge', 29 | level: logLevels.values.info, 30 | ...overrides, 31 | }); 32 | } 33 | 34 | function fakeProcess() { 35 | class FakeWritableStream extends Writable { 36 | write = () => true; 37 | } 38 | const fakeWritableStream = new FakeWritableStream(); 39 | sinon.spy(fakeWritableStream, 'write'); 40 | 41 | return { 42 | stdout: fakeWritableStream, 43 | }; 44 | } 45 | 46 | it('lets you turn on verbose logging', () => { 47 | const log = new ConsoleStream({ verbose: false }); 48 | log.makeVerbose(); 49 | assert.equal(log.verbose, true); 50 | }); 51 | 52 | it('logs names in verbose mode', () => { 53 | const log = new ConsoleStream({ verbose: true }); 54 | assert.equal( 55 | log.format({ 56 | name: 'foo', 57 | msg: 'some message', 58 | level: logLevels.values.debug, 59 | }), 60 | '[foo][debug] some message\n', 61 | ); 62 | }); 63 | 64 | it('does not log names in non-verbose mode', () => { 65 | const log = new ConsoleStream({ verbose: false }); 66 | assert.equal( 67 | log.format({ name: 'foo', msg: 'some message' }), 68 | 'some message\n', 69 | ); 70 | }); 71 | 72 | it('does not log debug packets unless verbose', () => { 73 | const log = new ConsoleStream({ verbose: false }); 74 | const localProcess = fakeProcess(); 75 | log.write(packet({ level: logLevels.values.debug }), { localProcess }); 76 | sinon.assert.notCalled(localProcess.stdout.write); 77 | }); 78 | 79 | it('does not log trace packets unless verbose', () => { 80 | const log = new ConsoleStream({ verbose: false }); 81 | const localProcess = fakeProcess(); 82 | log.write(packet({ level: logLevels.values.trace }), { localProcess }); 83 | sinon.assert.notCalled(localProcess.stdout.write); 84 | }); 85 | 86 | it('logs debug packets when verbose', () => { 87 | const log = new ConsoleStream({ verbose: true }); 88 | const localProcess = fakeProcess(); 89 | log.write(packet({ level: logLevels.values.debug }), { localProcess }); 90 | sinon.assert.called(localProcess.stdout.write); 91 | }); 92 | 93 | it('logs trace packets when verbose', () => { 94 | const log = new ConsoleStream({ verbose: true }); 95 | const localProcess = fakeProcess(); 96 | log.write(packet({ level: logLevels.values.trace }), { localProcess }); 97 | sinon.assert.called(localProcess.stdout.write); 98 | }); 99 | 100 | it('logs info packets when verbose or not', () => { 101 | const log = new ConsoleStream({ verbose: false }); 102 | const localProcess = fakeProcess(); 103 | log.write(packet({ level: logLevels.values.info }), { localProcess }); 104 | log.makeVerbose(); 105 | log.write(packet({ level: logLevels.values.info }), { localProcess }); 106 | sinon.assert.callCount(localProcess.stdout.write, 2); 107 | }); 108 | 109 | it('lets you capture logging', () => { 110 | const log = new ConsoleStream(); 111 | const localProcess = fakeProcess(); 112 | 113 | log.startCapturing(); 114 | log.write(packet({ msg: 'message' }), { localProcess }); 115 | sinon.assert.notCalled(localProcess.stdout.write); 116 | log.flushCapturedLogs({ localProcess }); 117 | sinon.assert.calledWith(localProcess.stdout.write, 'message\n'); 118 | }); 119 | 120 | it('only flushes captured messages once', () => { 121 | const log = new ConsoleStream(); 122 | let localProcess = fakeProcess(); 123 | 124 | log.startCapturing(); 125 | log.write(packet(), { localProcess }); 126 | log.flushCapturedLogs({ localProcess }); 127 | 128 | // Make sure there is nothing more to flush. 129 | localProcess = fakeProcess(); 130 | log.flushCapturedLogs({ localProcess }); 131 | sinon.assert.notCalled(localProcess.stdout.write); 132 | }); 133 | 134 | it('lets you start and stop capturing', () => { 135 | const log = new ConsoleStream(); 136 | let localProcess = fakeProcess(); 137 | 138 | log.startCapturing(); 139 | log.write(packet(), { localProcess }); 140 | sinon.assert.notCalled(localProcess.stdout.write); 141 | 142 | log.stopCapturing(); 143 | log.write(packet(), { localProcess }); 144 | sinon.assert.callCount(localProcess.stdout.write, 1); 145 | 146 | // Make sure that when we start capturing again, 147 | // the queue gets reset. 148 | log.startCapturing(); 149 | log.write(packet()); 150 | localProcess = fakeProcess(); 151 | log.flushCapturedLogs({ localProcess }); 152 | sinon.assert.callCount(localProcess.stdout.write, 1); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /tests/unit/test-util/test.promisify.js: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | 3 | import { describe, it } from 'mocha'; 4 | import { assert } from 'chai'; 5 | import * as sinon from 'sinon'; 6 | 7 | import { 8 | multiArgsPromisedFn, 9 | promisifyCustom, 10 | } from '../../../src/util/promisify.js'; 11 | 12 | describe('nodejs util.promisify', () => { 13 | it('wraps a nodejs callback-based function into a promised function', async () => { 14 | const expectedParam1 = 'param-value-1'; 15 | const expectedParam2 = 'param-value-2'; 16 | const expectedResult = { result: 'value' }; 17 | const expectedError = new Error('Fake error'); 18 | 19 | const fnCallSuccess = sinon.spy(function (param1, param2, cb) { 20 | setTimeout(() => cb(undefined, expectedResult), 0); 21 | }); 22 | 23 | const fnCallFailure = sinon.spy(function (param, cb) { 24 | setTimeout(() => cb(expectedError), 0); 25 | }); 26 | 27 | const fnCallThrow = sinon.spy(function fnCallThrow() { 28 | throw expectedError; 29 | }); 30 | 31 | const promisedFnSuccess = promisify(fnCallSuccess); 32 | const promisedFnFailure = promisify(fnCallFailure); 33 | const promisedFnThrow = promisify(fnCallThrow); 34 | 35 | // Test successfull promised function call. 36 | await assert.becomes( 37 | promisedFnSuccess(expectedParam1, expectedParam2), 38 | expectedResult, 39 | ); 40 | sinon.assert.calledOnce(fnCallSuccess); 41 | sinon.assert.calledWith( 42 | fnCallSuccess, 43 | expectedParam1, 44 | expectedParam2, 45 | sinon.match.func, 46 | ); 47 | 48 | // Test failed promised function call. 49 | await assert.isRejected(promisedFnFailure(expectedParam1), expectedError); 50 | sinon.assert.calledOnce(fnCallFailure); 51 | sinon.assert.calledWith(fnCallFailure, expectedParam1, sinon.match.func); 52 | 53 | // Test function call that throws. 54 | await assert.isRejected(promisedFnThrow(), expectedError); 55 | sinon.assert.calledOnce(fnCallThrow); 56 | sinon.assert.calledWith(fnCallThrow, sinon.match.func); 57 | }); 58 | }); 59 | 60 | describe('web-ext util.promisify.multiArgsPromisedFn custom helper', () => { 61 | it('optionally pass multiple results to a wrapped function', async () => { 62 | const expectedResults = ['result1', 'result2']; 63 | const expectedError = new Error('Fake error'); 64 | 65 | const fnCallMultiArgs = sinon.spy(function (behavior, cb) { 66 | if (behavior === 'throw') { 67 | throw expectedError; 68 | } else if (behavior === 'reject') { 69 | setTimeout(() => cb(expectedError)); 70 | } else { 71 | setTimeout(() => cb(undefined, ...expectedResults)); 72 | } 73 | }); 74 | 75 | fnCallMultiArgs[promisifyCustom] = multiArgsPromisedFn(fnCallMultiArgs); 76 | 77 | const promisedFnMultiArgs = promisify(fnCallMultiArgs); 78 | 79 | // Test success scenario. 80 | await assert.becomes(promisedFnMultiArgs(undefined), expectedResults); 81 | sinon.assert.calledOnce(fnCallMultiArgs); 82 | sinon.assert.calledWith(fnCallMultiArgs, undefined, sinon.match.func); 83 | 84 | // Test throw scenario. 85 | await assert.isRejected(promisedFnMultiArgs('throw'), expectedError); 86 | sinon.assert.calledTwice(fnCallMultiArgs); 87 | sinon.assert.calledWith(fnCallMultiArgs, 'throw', sinon.match.func); 88 | 89 | // Test reject scenario. 90 | await assert.isRejected(promisedFnMultiArgs('reject'), expectedError); 91 | sinon.assert.calledThrice(fnCallMultiArgs); 92 | sinon.assert.calledWith(fnCallMultiArgs, 'reject', sinon.match.func); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /tests/unit/test-util/test.temp-dir.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | 3 | import { describe, it } from 'mocha'; 4 | import { assert } from 'chai'; 5 | import * as sinon from 'sinon'; 6 | 7 | import { withTempDir, TempDir } from '../../../src/util/temp-dir.js'; 8 | 9 | describe('util.withTempDir', () => { 10 | it('creates a temp directory', () => 11 | withTempDir((tmpDir) => { 12 | // Make sure the directory exists. 13 | return fs.stat(tmpDir.path()); 14 | })); 15 | 16 | it('destroys the directory on completion', async () => { 17 | const tmpPath = await withTempDir((tmpDir) => { 18 | return tmpDir.path(); 19 | }); 20 | await assert.isRejected(fs.stat(tmpPath), /ENOENT.* stat/); 21 | }); 22 | 23 | it('destroys the directory on error', async () => { 24 | let tmpPath; 25 | let tmpPathExisted = false; 26 | 27 | await assert.isRejected( 28 | withTempDir(async (tmpDir) => { 29 | tmpPath = tmpDir.path(); 30 | tmpPathExisted = Boolean(await fs.stat(tmpPath)); 31 | throw new Error('simulated error'); 32 | }), 33 | 'simulated error', 34 | ); 35 | 36 | assert.equal(tmpPathExisted, true); 37 | await assert.isRejected(fs.stat(tmpPath), /ENOENT.* stat/); 38 | }); 39 | }); 40 | 41 | describe('util.TempDir', () => { 42 | it('requires you to create the directory before accessing path()', () => { 43 | const tmp = new TempDir(); 44 | assert.throws(() => tmp.path(), /cannot access path.* before.* create/); 45 | }); 46 | 47 | it('does not throw on remove called before a temp dir is created', async () => { 48 | const tmp = new TempDir(); 49 | assert.equal(tmp._removeTempDir, undefined); 50 | tmp.remove(); 51 | 52 | await tmp.create(); 53 | assert.equal(typeof tmp._removeTempDir, 'function'); 54 | 55 | tmp._removeTempDir = sinon.spy(tmp._removeTempDir); 56 | tmp.remove(); 57 | 58 | sinon.assert.calledOnce(tmp._removeTempDir); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/unit/test-util/test.updates.js: -------------------------------------------------------------------------------- 1 | import { it, describe } from 'mocha'; 2 | import * as sinon from 'sinon'; 3 | 4 | import { checkForUpdates } from '../../../src/util/updates.js'; 5 | 6 | describe('util/updates', () => { 7 | describe('checkForUpdates()', () => { 8 | it('calls the notifier with the correct parameters', () => { 9 | const updateNotifierStub = sinon.spy(() => { 10 | return { 11 | notify: sinon.spy(), 12 | }; 13 | }); 14 | 15 | checkForUpdates({ 16 | version: '1.0.0', 17 | updateNotifier: updateNotifierStub, 18 | }); 19 | 20 | sinon.assert.calledWithMatch(updateNotifierStub, { 21 | updateCheckInterval: 1000 * 60 * 60 * 24 * 3, 22 | pkg: { name: 'web-ext', version: '1.0.0' }, 23 | }); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/unit/test.errors.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha'; 2 | import { assert } from 'chai'; 3 | 4 | import { 5 | onlyErrorsWithCode, 6 | isErrorWithCode, 7 | onlyInstancesOf, 8 | } from '../../src/errors.js'; 9 | import { makeSureItFails, ErrorWithCode } from './helpers.js'; 10 | 11 | describe('errors', () => { 12 | describe('onlyInstancesOf', () => { 13 | it('lets you catch a certain error', () => { 14 | return Promise.reject(new SyntaxError('simulated error')).catch( 15 | onlyInstancesOf(SyntaxError, (error) => { 16 | assert.instanceOf(error, SyntaxError); 17 | }), 18 | ); 19 | }); 20 | 21 | it('throws instances of other errors', () => { 22 | return Promise.reject(new SyntaxError('simulated error')) 23 | .catch( 24 | onlyInstancesOf(TypeError, () => { 25 | throw new Error('Unexpectedly caught the wrong error'); 26 | }), 27 | ) 28 | .then(makeSureItFails()) 29 | .catch((error) => { 30 | assert.match(error.message, /simulated error/); 31 | }); 32 | }); 33 | }); 34 | 35 | describe('onlyErrorsWithCode', () => { 36 | class ErrorWithErrno extends Error { 37 | errno; 38 | constructor() { 39 | super('pretend this is a system error'); 40 | this.errno = 53; 41 | } 42 | } 43 | 44 | it('catches errors having a code', () => { 45 | return Promise.reject(new ErrorWithCode()).catch( 46 | onlyErrorsWithCode('SOME_CODE', (error) => { 47 | assert.equal(error.code, 'SOME_CODE'); 48 | }), 49 | ); 50 | }); 51 | 52 | it('catches errors having a error no', () => { 53 | return Promise.reject(new ErrorWithErrno()).catch( 54 | onlyErrorsWithCode(53, (error) => { 55 | assert.equal(error.errno, 53); 56 | }), 57 | ); 58 | }); 59 | 60 | it('throws errors that do not match the code', () => { 61 | return Promise.reject(new SyntaxError('simulated error')) 62 | .catch( 63 | onlyErrorsWithCode('SOME_CODE', () => { 64 | throw new Error('Unexpectedly caught the wrong error'); 65 | }), 66 | ) 67 | .then(makeSureItFails()) 68 | .catch((error) => { 69 | assert.match(error.message, /simulated error/); 70 | }); 71 | }); 72 | 73 | it('catches errors having one of many codes', () => { 74 | return Promise.reject(new ErrorWithCode()).catch( 75 | onlyErrorsWithCode(['OTHER_CODE', 'SOME_CODE'], (error) => { 76 | assert.equal(error.code, 'SOME_CODE'); 77 | }), 78 | ); 79 | }); 80 | 81 | it('catches errors having one of many errno', () => { 82 | return Promise.reject(new ErrorWithErrno()).catch( 83 | onlyErrorsWithCode([34, 53], (error) => { 84 | assert.equal(error.errno, 53); 85 | }), 86 | ); 87 | }); 88 | 89 | it('throws errors that are not in an array of codes', () => { 90 | return Promise.reject(new ErrorWithCode()) 91 | .catch( 92 | onlyErrorsWithCode(['OTHER_CODE', 'ANOTHER_CODE'], () => { 93 | throw new Error('Unexpectedly caught the wrong error'); 94 | }), 95 | ) 96 | .then(makeSureItFails()) 97 | .catch((error) => { 98 | assert.equal(error.code, 'SOME_CODE'); 99 | }); 100 | }); 101 | }); 102 | 103 | describe('isErrorWithCode', () => { 104 | it('returns true on errors that do match the code', () => { 105 | assert.equal(isErrorWithCode('SOME_CODE', new ErrorWithCode()), true); 106 | assert.equal( 107 | isErrorWithCode(['SOME_CODE', 'OTHER_CODE'], new ErrorWithCode()), 108 | true, 109 | ); 110 | }); 111 | 112 | it('returns false on errors that do not match the code', () => { 113 | assert.equal(isErrorWithCode('OTHER_CODE', new ErrorWithCode()), false); 114 | assert.equal( 115 | isErrorWithCode(['OTHER_CODE', 'ANOTHER_CODE'], new ErrorWithCode()), 116 | false, 117 | ); 118 | assert.equal(isErrorWithCode('ANY_CODE', new Error()), false); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /tests/unit/test.setup.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, afterEach } from 'mocha'; 2 | 3 | import { consoleStream } from '../../src/util/logger.js'; 4 | 5 | beforeEach(function () { 6 | consoleStream.makeVerbose(); 7 | consoleStream.startCapturing(); 8 | }); 9 | 10 | afterEach(function () { 11 | if (this.currentTest.state !== 'passed') { 12 | consoleStream.flushCapturedLogs(); 13 | } 14 | consoleStream.stopCapturing(); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/unit/test.watcher.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import path from 'path'; 3 | import fs from 'fs/promises'; 4 | 5 | import { it, describe } from 'mocha'; 6 | import * as sinon from 'sinon'; 7 | import { assert } from 'chai'; 8 | import Watchpack from 'watchpack'; 9 | 10 | import { 11 | default as onSourceChange, 12 | proxyFileChanges, 13 | } from '../../src/watcher.js'; 14 | import { withTempDir } from '../../src/util/temp-dir.js'; 15 | import { makeSureItFails } from './helpers.js'; 16 | 17 | describe('watcher', () => { 18 | const watchChange = ({ watchFile, touchedFile } = {}) => 19 | withTempDir(async (tmpDir) => { 20 | const artifactsDir = path.join(tmpDir.path(), 'web-ext-artifacts'); 21 | const someFile = path.join(tmpDir.path(), touchedFile); 22 | 23 | if (watchFile) { 24 | watchFile = watchFile.map((f) => path.join(tmpDir.path(), f)); 25 | } 26 | 27 | let resolveChange; 28 | const whenFilesChanged = new Promise((resolve) => { 29 | resolveChange = resolve; 30 | }); 31 | const onChange = sinon.spy(() => { 32 | resolveChange(); 33 | }); 34 | 35 | await fs.writeFile(someFile, '<contents>'); 36 | const watcher = onSourceChange({ 37 | sourceDir: tmpDir.path(), 38 | watchFile, 39 | artifactsDir, 40 | onChange, 41 | shouldWatchFile: () => true, 42 | }); 43 | 44 | const { fileWatchers, directoryWatchers } = watcher; 45 | let watchedFilePath; 46 | let watchedDirPath; 47 | 48 | if (fileWatchers?.size > 0) { 49 | watchedFilePath = Array.from(fileWatchers.keys())[0]; 50 | } 51 | 52 | if (directoryWatchers?.size > 0) { 53 | watchedDirPath = Array.from(directoryWatchers.keys())[0]; 54 | } 55 | 56 | await fs.utimes(someFile, Date.now() / 1000, Date.now() / 1000); 57 | const assertParams = { 58 | onChange, 59 | watchedFilePath, 60 | watchedDirPath, 61 | tmpDirPath: tmpDir.path(), 62 | }; 63 | 64 | return Promise.race([ 65 | whenFilesChanged.then(() => { 66 | watcher.close(); 67 | // This delay seems to avoid stat errors from the watcher 68 | // which can happen when the temp dir is deleted (presumably 69 | // before watcher.close() has removed all listeners). 70 | return new Promise((resolve) => { 71 | setTimeout(resolve, 2, assertParams); 72 | }); 73 | }), 74 | // Time out if no files are changed 75 | new Promise((resolve) => 76 | setTimeout(() => { 77 | watcher.close(); 78 | resolve(assertParams); 79 | }, 500), 80 | ), 81 | ]); 82 | }); 83 | 84 | it('watches for changes in the sourceDir', async () => { 85 | const defaultDebounce = 500; 86 | const { onChange, watchedFilePath, watchedDirPath, tmpDirPath } = 87 | await watchChange({ 88 | touchedFile: 'foo.txt', 89 | }); 90 | 91 | await new Promise((resolve) => setTimeout(resolve, defaultDebounce + 50)); 92 | 93 | sinon.assert.calledOnce(onChange); 94 | assert.equal(watchedDirPath, tmpDirPath); 95 | assert.isUndefined(watchedFilePath); 96 | }); 97 | 98 | describe('--watch-file option is passed in', () => { 99 | it('changes if the watched file is touched', async () => { 100 | const { onChange, watchedFilePath, watchedDirPath, tmpDirPath } = 101 | await watchChange({ 102 | watchFile: ['foo.txt'], 103 | touchedFile: 'foo.txt', 104 | }); 105 | 106 | sinon.assert.calledOnce(onChange); 107 | assert.isUndefined(watchedDirPath); 108 | assert.equal(watchedFilePath, path.join(tmpDirPath, 'foo.txt')); 109 | }); 110 | 111 | it('does not change if watched file is not touched', async () => { 112 | const { onChange, watchedFilePath, watchedDirPath, tmpDirPath } = 113 | await watchChange({ 114 | watchFile: ['bar.txt'], 115 | touchedFile: 'foo.txt', 116 | }); 117 | 118 | sinon.assert.notCalled(onChange); 119 | assert.isUndefined(watchedDirPath); 120 | assert.equal(watchedFilePath, path.join(tmpDirPath, 'bar.txt')); 121 | }); 122 | 123 | it('throws error if a non-file is passed into --watch-file', () => { 124 | return watchChange({ 125 | watchFile: ['/'], 126 | touchedFile: 'foo.txt', 127 | }) 128 | .then(makeSureItFails()) 129 | .catch((error) => { 130 | assert.match( 131 | error.message, 132 | /Invalid --watch-file value: .+ is not a file./, 133 | ); 134 | }); 135 | }); 136 | }); 137 | 138 | describe('proxyFileChanges', () => { 139 | const defaults = { 140 | artifactsDir: '/some/artifacts/dir/', 141 | onChange: () => {}, 142 | shouldWatchFile: () => true, 143 | }; 144 | 145 | it('proxies file changes', () => { 146 | const onChange = sinon.spy(() => {}); 147 | proxyFileChanges({ 148 | ...defaults, 149 | filePath: '/some/file.js', 150 | onChange, 151 | }); 152 | sinon.assert.called(onChange); 153 | }); 154 | 155 | it('ignores changes to artifacts', () => { 156 | const onChange = sinon.spy(() => {}); 157 | proxyFileChanges({ 158 | ...defaults, 159 | filePath: '/some/artifacts/dir/build.xpi', 160 | artifactsDir: '/some/artifacts/dir/', 161 | onChange, 162 | }); 163 | sinon.assert.notCalled(onChange); 164 | }); 165 | 166 | it('provides a callback for ignoring files', () => { 167 | function shouldWatchFile(filePath) { 168 | if (filePath === '/somewhere/freaky') { 169 | return false; 170 | } else { 171 | return true; 172 | } 173 | } 174 | 175 | const conf = { 176 | ...defaults, 177 | shouldWatchFile, 178 | onChange: sinon.spy(() => {}), 179 | }; 180 | 181 | proxyFileChanges({ ...conf, filePath: '/somewhere/freaky' }); 182 | sinon.assert.notCalled(conf.onChange); 183 | proxyFileChanges({ ...conf, filePath: '/any/file/' }); 184 | sinon.assert.called(conf.onChange); 185 | }); 186 | }); 187 | 188 | describe('--watch-ignored is passed in', () => { 189 | it('does not call onChange if ignored file is touched', () => 190 | withTempDir(async (tmpDir) => { 191 | const debounceTime = 10; 192 | const onChange = sinon.spy(); 193 | const tmpPath = tmpDir.path(); 194 | const files = ['foo.txt', 'bar.txt', 'foobar.txt'].map((filePath) => 195 | path.join(tmpPath, filePath), 196 | ); 197 | 198 | const watcher = onSourceChange({ 199 | sourceDir: tmpPath, 200 | artifactsDir: path.join(tmpPath, 'web-ext-artifacts'), 201 | onChange, 202 | watchIgnored: ['foo.txt'].map((filePath) => 203 | path.join(tmpPath, filePath), 204 | ), 205 | shouldWatchFile: (filePath) => filePath !== tmpPath, 206 | debounceTime, 207 | }); 208 | 209 | const watchAll = new Watchpack(); 210 | watchAll.watch({ files, directories: [], missing: [], startTime: 0 }); 211 | 212 | async function waitDebounce() { 213 | await new Promise((resolve) => setTimeout(resolve, debounceTime * 2)); 214 | } 215 | 216 | async function assertOnChange(filePath, expectedCallCount) { 217 | const promiseOnChanged = new Promise((resolve) => 218 | watchAll.once('change', (f) => resolve(f)), 219 | ); 220 | await waitDebounce(); 221 | await fs.writeFile(filePath, '<content>'); 222 | assert.equal(filePath, await promiseOnChanged); 223 | await waitDebounce(); 224 | sinon.assert.callCount(onChange, expectedCallCount); 225 | } 226 | 227 | // Verify foo.txt is being ignored. 228 | await assertOnChange(files[0], 0); 229 | 230 | // Verify that the other two files are not be ignored. 231 | await assertOnChange(files[1], 1); 232 | await assertOnChange(files[2], 2); 233 | 234 | watcher.close(); 235 | watchAll.close(); 236 | // Leave watcher.close some time to complete its cleanup before withTempDir will remove the 237 | // test directory. 238 | await waitDebounce(); 239 | })); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /tests/unit/test.web-ext.js: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, it } from 'mocha'; 2 | import { assert } from 'chai'; 3 | import * as sinon from 'sinon'; 4 | 5 | import { mockModule, resetMockModules } from './helpers.js'; 6 | import webExt from '../../src/main.js'; 7 | import { main } from '../../src/program.js'; 8 | 9 | describe('webExt', () => { 10 | it('exposes main', () => { 11 | assert.equal(webExt.main, main); 12 | }); 13 | 14 | describe('exposes commands', () => { 15 | let stub; 16 | afterEach(() => { 17 | resetMockModules(); 18 | stub = undefined; 19 | }); 20 | for (const cmd of ['run', 'lint', 'build', 'sign', 'docs', 'dump-config']) { 21 | it(`lazily loads cmd/${cmd}`, async () => { 22 | const cmdModule = await import(`../../src/cmd/${cmd}.js`); 23 | stub = sinon.stub({ default: cmdModule.default }, 'default'); 24 | 25 | mockModule({ 26 | moduleURL: `../../src/cmd/${cmd}.js`, 27 | importerModuleURL: import.meta.url, 28 | namedExports: {}, 29 | defaultExport: stub, 30 | }); 31 | 32 | const params = {}; 33 | const options = {}; 34 | const expectedResult = {}; 35 | stub?.returns(expectedResult); 36 | 37 | const { default: webExtModule } = await import('../../src/main.js'); 38 | const runCommand = 39 | webExtModule.cmd[cmd === 'dump-config' ? 'dumpConfig' : cmd]; 40 | const result = await runCommand(params, options); 41 | 42 | // Check whether parameters and return values are forwarded as-is. 43 | sinon.assert.calledOnce(stub); 44 | sinon.assert.calledWithExactly(stub, params, options); 45 | assert.equal(expectedResult, result); 46 | }); 47 | } 48 | }); 49 | }); 50 | --------------------------------------------------------------------------------