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.
Install with skills.sh (any agent)
npx skills add testland/qa --skill web-ext-cli-mozillaweb-ext-cli-mozilla
Overview
web-ext is Mozilla's reference CLI for WebExtension development. Per the mozilla/web-ext README (opens in new window), 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 (opens in new window)).
This skill wraps web-ext for test runs. Composes with:
For Playwright-driven MV3 popup / content-script fixtures see 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 (opens in new window), 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 (opens in new window)) - 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 (opens in new window), --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 (opens in new window)) 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 (opens in new window) and emits AMO-compatible warnings. Per we-cmd (opens in new window) 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 (opens in new window) 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 (opens in new window), 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 (opens in new window)): 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 (opens in new window)) persists profile modifications across runs - useful for snapshot-style tests.
Run on Chromium
Per we-cmd (opens in new window) (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 (opens in new window), 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 (opens in new window), --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 (opens in new window), 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 (opens in new window)):
"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/ per we-cmd (opens in new window); an API base path, not a browsable page |
To submit updates, per we-cmd (opens in new window) 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 (opens in new window)'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 (opens in new window) 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 (opens in new window); 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 (opens in new window) |
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 (opens in new window) | 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 |
Limitations
References
Related skills
chrome-extension-messaging-tests
Asserts Chrome extension message-passing behaviour against a running extension: one-shot `chrome.runtime.sendMessage` plus the literal `return true` that holds the response channel open for an async `sendResponse`, `chrome.tabs.sendMessage` into one tab's content script, long-lived `chrome.runtime.connect` ports and their `onDisconnect` triggers, web-page messages gated by `externally_connectable`, and `chrome.runtime.connectNative` native-messaging hosts. Covers the payload rules a test must respect (Chrome uses JSON serialization rather than structured clone, so `Map` / `Set` / `Date` do not round-trip; maximum message size is 64 MiB) and the first-listener-wins rule when several `onMessage` listeners are registered. Scope is messaging behaviour on an already-running extension, not the install or reload step. Use when a message reaches no listener, a `sendResponse` callback never fires, a port disconnects mid-test, or a page origin has to be proven allowed before publishing.
chrome-extension-test-loader
Loads an unpacked Chrome / Chromium extension for testing through the `chrome://extensions` Developer-mode flow: the minimum loadable `manifest.json`, the Load-unpacked directory-not-file selection, toolbar pinning, and the reload matrix deciding what a code edit actually re-evaluates (`manifest.json`, the background service worker, and content scripts need an explicit card refresh, content scripts additionally need a host-page refresh, while popup / options / other extension HTML pages re-evaluate on next open). Also covers reading the red Errors card, where service-worker and content-script logs surface, and the `--load-extension` and `web-ext --target chromium` equivalents that move the same load into CI. Scope is getting a build directory loaded and reloaded, not the runtime behaviour asserted afterwards. Use when a freshly built extension directory has to go into Chrome for the first time, or when an edit appears to have no effect and you need to know which surface requires an explicit reload.
extension-storage-test-author
Build-an-X workflow that emits a `chrome.storage` test suite. Picks the right area (`storage.local` 10 MB / `storage.sync` 100 KB total + 8 KB per item + 512 items + 1,800 writes/hour / `storage.session` 10 MB in-memory MV3-only / `storage.managed` read-only enterprise-policy) per access pattern, then generates tests for quota-exceeded behavior (`runtime.lastError` callback path + rejected promise async path), `storage.sync` per-item + total quotas, `storage.onChanged` event payload shape, multi-area write isolation, and Firefox-Chrome divergences (Firefox `storage.sync` quotas align with Chrome per MDN; Firefox `storage.managed` available; Firefox `storage.session` MV3-only). Output: a per-extension storage test file + matrix asserting the right area was chosen. Use when an extension persists state across sessions or devices and no test proves the chosen storage area survives its quota limits.
manifest-v3-test-surface-reference
Pure-reference catalog of the Manifest V3 test surface for Firefox + Chromium browser extensions. Maps each manifest field that changed from MV2 (manifest_version, background.service_worker vs background.scripts, action vs browser_action / page_action, host_permissions split, web_accessible_resources object-form, content_security_policy object-form), the runtime restrictions service workers impose (no DOM, no XMLHttpRequest, no localStorage, ephemeral lifecycle, synchronous listener registration, alarms instead of setTimeout), and the Firefox-vs-Chrome key matrix (browser_specific_settings.gecko, externally_connectable / offline_enabled gaps, MV2-only user_scripts manifest key). Use as the manifest-surface reference when authoring extension tests across both browsers.
mv2-to-mv3-migration-test-checklist
Build-an-X workflow that emits a per-extension MV2 → MV3 migration test checklist. Walks the six canonical migration sections (manifest, service worker, API calls, declarative net request, security, publication) per the Chrome migration checklist, then for each one inventories the source MV2 manifest, names the MV3 replacement field / API, and emits the verification test cases. Covers the Firefox-Chrome divergence cells (page_action retained in Firefox, event pages allowed in Firefox 106+, host-permission install-prompt behavior changed in Firefox 127, web_accessible_resources `use_dynamic_url` Chromium-only). Output: a checklist artifact with per-section test cases the migrating extension must pass before publishing the MV3 build. Use when migrating an MV2 extension to Manifest V3 and the team needs section-by-section evidence the migration is complete.
playwright-extension-fixtures
Author the lower-level Playwright fixture pattern that every Chromium extension test depends on - `chromium.launchPersistentContext` with `--disable-extensions-except=$DIR` + `--load-extension=$DIR`, the `channel: 'chromium'` selection that unlocks headless extension support, the `context.serviceWorkers()` + `waitForEvent('serviceworker')` race-handling pattern, and the `extensionId = serviceWorker.url().split('/')[2]` extraction recipe. This is the launch-and-load layer shared by every extension test, not the assertions run on top of it. Use when authoring or debugging the fixture a Chromium extension test imports - popup, content script, service worker, options page, or side panel.