web-ext-cli-mozilla
Author, lint, run, build, and sign a Firefox / Chromium WebExtension using Mozilla's `web-ext` CLI v8. Covers `web-ext lint` (addons-linter wrapper, JSON output for CI), `web-ext run` (temporary install in firefox-desktop / firefox-android / chromium targets with hot-reload), `web-ext build` (deterministic zip), and `web-ext sign` (AMO submission API, listed vs unlisted channels, JWT credentials). Use when the extension targets Firefox (signing is mandatory for distribution) or when cross-browser test runs need a single CLI that drives both Firefox and Chromium against the same source tree. For Playwright-driven MV3 popup / content-script fixtures see `qa-modern-web/browser-extension-tests`. This plugin covers Firefox + Chrome extension lifecycle, MV2 → MV3 migration, host-permission prompts, and `storage.sync` vs `storage.local` semantics.
web-ext-cli-mozilla
Overview
web-ext is Mozilla's reference CLI for WebExtension development. Per the mozilla/web-ext README, it bundles five subcommands: run, lint, sign, build, and docs. web-ext v8 added a dump-config subcommand and reworked the signing flow to use the AMO submission API by default (per the web-ext command reference).
This skill wraps web-ext for test runs. Composes with:
For Playwright-driven MV3 popup / content-script fixtures see qa-modern-web/browser-extension-tests. That skill is Chromium-only and assumes the extension is built; web-ext is the lint+run+build+sign tool that produces the artifact and validates it pre-flight on either browser.
When to use
Authoring
Install
Per we-readme, install globally or per-project:
# Global
npm install --global web-ext
# Per-project (preferred for CI determinism)
npm install --save-dev web-extMozilla recommends "the current LTS (long term support) version of NodeJS" (per we-readme) - verify your CI image matches before asserting reproducibility.
Project layout assumed by web-ext
my-extension/
manifest.json
background.js
content.js
popup/
popup.html
popup.js
icons/
web-ext-config.cjs # optional, see belowweb-ext operates on the directory passed via --source-dir (or -s); per we-cmd, --source-dir is a global option - available to every subcommand - and --artifacts-dir (default ./web-ext-artifacts) controls where build / sign write zips.
Optional web-ext-config.cjs
A config file lets CI assert the same flags every run. Run web-ext dump-config (new in v8 per we-cmd) to print the resolved configuration as JSON for diffing.
// web-ext-config.cjs
module.exports = {
sourceDir: './dist',
artifactsDir: './build/artifacts',
run: {
firefox: 'nightly',
startUrl: ['about:debugging#/runtime/this-firefox'],
},
build: {
overwriteDest: true,
filename: 'my-extension-{version}.zip',
},
lint: {
warningsAsErrors: true,
output: 'json',
},
};Running
Lint
web-ext lint wraps mozilla/addons-linter and emits AMO-compatible warnings. Per we-cmd the flag list is:
| Flag | Effect |
|---|---|
--output / -o | json or text |
--metadata | output only metadata as JSON |
--pretty | format JSON output |
--self-hosted | declares self-hosting; disables AMO-related messages |
--boring | disables colored shell output |
--warnings-as-errors / -w | treat warnings as errors |
CI-friendly invocation:
web-ext lint \
--source-dir ./dist \
--output json \
--pretty \
--warnings-as-errors > lint-report.json--warnings-as-errors is the right CI default - by we-cmd this escalates lint warnings (e.g., unsupported manifest fields, MV2-only APIs flagged for MV3) into a non-zero exit code.
Run (Firefox desktop)
Per we-cmd, web-ext run builds the extension and installs it into a fresh temporary Firefox profile, then watches the source directory for changes and reloads on edit:
web-ext run \
--source-dir ./dist \
--firefox=firefox \
--start-url 'about:debugging#/runtime/this-firefox'Firefox alias values accepted by --firefox (per we-cmd): firefox, beta, nightly, deved / firefoxdeveloperedition, or a full path to a Firefox binary.
To pin a profile (e.g., to retain auth state between runs):
web-ext run \
--firefox-profile=qa-profile \
--profile-create-if-missing \
--keep-profile-changes--keep-profile-changes (per we-cmd) persists profile modifications across runs - useful for snapshot-style tests.
Run on Chromium
Per we-cmd (the --target flag), web-ext run supports three targets: firefox-desktop, firefox-android, and chromium. To smoke-test cross-browser:
web-ext run \
--source-dir ./dist \
--target chromium \
--chromium-binary "$(which chromium)"This is the lowest-effort way to verify a Firefox-developed extension at least loads on Chromium without a full Playwright fixture. Deep Chromium-specific test surface lives in chrome-extension-test-loader and playwright-extension-fixtures.
Run on Firefox Android
web-ext run \
--target firefox-android \
--android-device emulator-5554 \
--firefox-apk org.mozilla.firefoxPer we-cmd, the --adb-* family of flags wires adb for the device handshake (--adb-bin, --adb-port, --adb-host, --adb-device/--android-device, --adb-remove-old-artifacts).
Build
web-ext build \
--source-dir ./dist \
--artifacts-dir ./build/artifacts \
--overwrite-dest \
--filename 'my-extension-{version}.zip'Per we-cmd, --filename / -n defaults to {name}-{version}.zip. --overwrite-dest / -o is required when the same artefact path already exists (e.g., re-running build in the same CI job).
Sign
Per we-cmd, web-ext sign v8 uses the AMO submission API by default; --channel is required.
export WEB_EXT_API_KEY='user:12345:1'
export WEB_EXT_API_SECRET='abcdef...'
web-ext sign \
--source-dir ./dist \
--channel listed \
--amo-metadata ./amo-metadata.json \
--upload-source-code ./source.tar.gzChannel semantics (quoted from we-cmd):
"with
listedthe extension 'gets submitted for public listing'; withunlistedit 'gets submitted for signing for self-distribution.'"
| Flag | Effect |
|---|---|
--api-key (env $WEB_EXT_API_KEY) | JWT issuer for AMO API |
--api-secret (env $WEB_EXT_API_SECRET) | JWT secret |
--channel | required; listed or unlisted |
--amo-metadata | path to JSON with AMO listing metadata; required for first listed version |
--upload-source-code | path to source archive (v8 addition) |
--timeout | default 300000 ms |
--approval-timeout | default 900000 ms (v8 addition) |
--amo-base-url | default https://addons.mozilla.org/api/v5/ |
To submit updates, per we-cmd the manifest must include an extension ID (browser_specific_settings.gecko.id for Firefox).
Parsing results
Lint output shape
With --output json --pretty, web-ext lint emits an addons-linter report:
{
"count": 2,
"summary": { "errors": 1, "notices": 0, "warnings": 1 },
"metadata": { "manifestVersion": 3, "type": "extension", ... },
"errors": [
{
"_type": "error",
"code": "MANIFEST_FIELD_INVALID",
"message": "...",
"description": "...",
"file": "manifest.json",
"line": 12,
"column": 5
}
],
"warnings": [...]
}(Field shape per we-readme's reference to mozilla/addons-linter; exact fields stable across recent versions but spot-check the addons-linter changelog before pinning a parser.)
Parse with jq:
jq '.summary.errors + .summary.warnings' lint-report.json
# > 0 means CI should fail under --warnings-as-errorsBuild output
web-ext build prints the artefact path to stdout and exits non-zero on lint failure (build runs lint first). Capture via:
artefact=$(web-ext build -s ./dist -a ./out --overwrite-dest \
| grep 'Your web extension is ready' \
| sed -E 's/.*: (.+)$/\1/')Sign output
web-ext sign exits zero on signed-and-downloaded; non-zero on AMO rejection or timeout. Signed .xpi lands in --artifacts-dir.
CI integration
GitHub Actions example, gated lint + build on every PR + sign on tag:
name: extension-ci
on:
pull_request:
push:
tags: ['v*']
jobs:
lint-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 'lts/*' }
- run: npm ci
- name: Lint
run: npx web-ext lint -s ./dist -o json --pretty -w > lint-report.json
- name: Build
run: npx web-ext build -s ./dist -a ./out --overwrite-dest
- uses: actions/upload-artifact@v4
with:
name: lint+xpi
path: |
lint-report.json
out/*.zip
sign:
if: startsWith(github.ref, 'refs/tags/v')
needs: lint-build
runs-on: ubuntu-latest
env:
WEB_EXT_API_KEY: ${{ secrets.AMO_JWT_ISSUER }}
WEB_EXT_API_SECRET: ${{ secrets.AMO_JWT_SECRET }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 'lts/*' }
- run: npm ci
- name: Sign (listed)
run: |
npx web-ext sign \
-s ./dist \
--channel listed \
--amo-metadata ./amo-metadata.json--warnings-as-errors in the lint step is what gates the PR - per we-cmd it converts warnings into exit-1.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
web-ext sign without --channel | v8 requires --channel per we-cmd; command refuses to run | Always specify listed or unlisted |
Re-using the same profile (--firefox-profile) without --keep-profile-changes | Profile changes lost between runs; tests appear non-deterministic | Add --keep-profile-changes per we-cmd |
web-ext lint without -w in CI | Warnings silently pass; AMO submission still rejects | Use --warnings-as-errors / -w |
| Signing without an extension ID in manifest | Update submission fails per we-cmd | Add browser_specific_settings.gecko.id (see manifest-v3-test-surface-reference) |
Using --firefox-preview (removed in v8) | Flag removed; command errors | Pin web-ext version or migrate to the supported aliases |
Committing web-ext-artifacts/ | Repo bloats with binaries | Add to .gitignore; rely on CI artefact upload |
Calling web-ext run on production builds | Hot-reload modifies the profile; not a smoke-test surface | Use web-ext build + a separate Playwright fixture |