├── .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('', '')
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 ', () =>
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 = '';
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, '');
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, '');
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 |
--------------------------------------------------------------------------------