browser-extension-manager 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +268 -0
- package/README.md +4 -0
- package/dist/commands/install.js +2 -2
- package/dist/commands/setup.js +2 -1
- package/dist/defaults/.github/workflows/publish.yml +4 -1
- package/dist/lib/safe-install.js +13 -0
- package/docs/audit.md +67 -0
- package/docs/auth.md +147 -0
- package/docs/build-system.md +131 -0
- package/docs/cdp-debugging.md +45 -0
- package/docs/cli.md +70 -0
- package/docs/common-mistakes.md +10 -0
- package/docs/components.md +87 -0
- package/docs/css.md +91 -0
- package/docs/defaults.md +54 -0
- package/docs/environment-detection.md +79 -0
- package/docs/extension.md +94 -0
- package/docs/hooks.md +101 -0
- package/docs/icons.md +47 -0
- package/docs/logging.md +33 -0
- package/docs/managers.md +100 -0
- package/docs/offscreen.md +57 -0
- package/docs/publishing.md +175 -0
- package/docs/templating.md +66 -0
- package/docs/test-boot-layer.md +141 -0
- package/docs/test-framework.md +404 -0
- package/docs/themes.md +77 -0
- package/docs/translations.md +72 -0
- package/docs/xss-prevention.md +95 -0
- package/package.json +5 -2
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Test Framework — Boot Layer
|
|
2
|
+
|
|
3
|
+
The `boot` layer spawns headless Chromium with the **consumer's actual built extension** loaded as unpacked, then runs `inspect` callbacks against the live runtime. Replaces shell-level "did the extension load?" smoke tests with deterministic, signal-driven pass/fail.
|
|
4
|
+
|
|
5
|
+
## What boot tests verify
|
|
6
|
+
|
|
7
|
+
Things that ONLY break when the whole pipeline assembles correctly:
|
|
8
|
+
- The packaged manifest is valid strict JSON (no JSON5 leakage)
|
|
9
|
+
- All referenced files (background.service_worker, content scripts, popup HTML) exist on disk
|
|
10
|
+
- The service worker boots without errors
|
|
11
|
+
- `chrome.runtime.id` is assigned (extension successfully registered)
|
|
12
|
+
- The popup page loads via `chrome-extension://<id>/<popup_path>`
|
|
13
|
+
- Messages between popup and background round-trip
|
|
14
|
+
|
|
15
|
+
If a boot test passes, the extension at minimum *loads* in a real Chrome — that alone catches a class of bugs unit tests can't (missing assets, manifest schema drift, file path typos).
|
|
16
|
+
|
|
17
|
+
## Test file shape
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
module.exports = {
|
|
21
|
+
layer: 'boot',
|
|
22
|
+
description: 'extension loads + popup renders',
|
|
23
|
+
timeout: 20000,
|
|
24
|
+
inspect: async ({ extension, page, expect, projectRoot }) => {
|
|
25
|
+
expect(extension.manifest.manifest_version).toBe(3);
|
|
26
|
+
await page.goto(extension.popupUrl, { waitUntil: 'domcontentloaded' });
|
|
27
|
+
const html = await page.content();
|
|
28
|
+
expect(html).toContain('<html');
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or as a group:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
module.exports = {
|
|
37
|
+
type: 'group',
|
|
38
|
+
layer: 'boot',
|
|
39
|
+
description: 'extension boots end-to-end',
|
|
40
|
+
tests: [
|
|
41
|
+
{
|
|
42
|
+
description: 'extension has a valid ID',
|
|
43
|
+
inspect: async ({ extension, expect }) => {
|
|
44
|
+
expect(extension.id).toMatch(/^[a-z]{32}$/);
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
description: 'service worker came up',
|
|
49
|
+
inspect: async ({ extension, expect }) => {
|
|
50
|
+
expect(extension.swTarget).not.toBeNull();
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## The `inspect` callback args
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
inspect: async ({ extension, page, expect, projectRoot }) => { /* ... */ }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Arg | Type | Description |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `extension.id` | string | The extension's chrome-extension://`<id>` ID — random per launch, 32-char a-z |
|
|
66
|
+
| `extension.manifest` | object | Parsed manifest.json |
|
|
67
|
+
| `extension.popupUrl` | string\|null | `chrome-extension://<id>/<manifest.action.default_popup>` or null |
|
|
68
|
+
| `extension.optionsUrl` | string\|null | `chrome-extension://<id>/<manifest.options_ui.page>` or null |
|
|
69
|
+
| `extension.swTarget` | Puppeteer Target | The service worker target (may be null if extension has no SW) |
|
|
70
|
+
| `page` | Puppeteer Page | A fresh tab — use `page.goto`, `page.evaluate`, `page.$eval`, etc. |
|
|
71
|
+
| `expect` | function | Jest-compatible matcher (same surface as other layers) |
|
|
72
|
+
| `projectRoot` | string | Absolute path to the consumer project |
|
|
73
|
+
|
|
74
|
+
Each boot test gets a **fresh** `page` (closed at the end of the test). The browser + extension load are shared across all boot tests in a single `npx bxm test` invocation (one Chromium boot per run, amortized across tests).
|
|
75
|
+
|
|
76
|
+
## Extension-directory discovery
|
|
77
|
+
|
|
78
|
+
The runner looks for the consumer's Chrome-loadable build in this order:
|
|
79
|
+
|
|
80
|
+
1. `BXM_TEST_BOOT_DIR` env var (absolute path) — full override
|
|
81
|
+
2. `<consumer>/packaged/chromium/raw/` — default. This is what BXM's gulp pipeline produces. Strict JSON manifest, all bundles compiled, locale files in place. Same dir a developer points "Load unpacked" at.
|
|
82
|
+
3. `<consumer>/dist/` — fallback for non-standard pipelines
|
|
83
|
+
|
|
84
|
+
The intermediate `<consumer>/dist/` typically has a JSON5 manifest (BXM-authored source style) which Chrome can't parse. If the runner picks `dist/` and finds JSON5, you get an actionable error:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
✗ boot tests aborted: dist/manifest.json is not strict JSON.
|
|
88
|
+
Chrome requires manifest.json to have no comments, no trailing commas, no single quotes.
|
|
89
|
+
Parser error: Expected property name or '}' in JSON at position 4
|
|
90
|
+
If you see this, the runner picked an intermediate dist/ output instead of a
|
|
91
|
+
packaged/<browser>/raw/ output. Run `npm run build` to produce the packaged dir,
|
|
92
|
+
or set BXM_TEST_BOOT_DIR to the directory that has strict-JSON manifest.json.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Most consumers don't need to think about this — `npm run build && npx bxm test` works.
|
|
96
|
+
|
|
97
|
+
## BXM_TEST_BOOT_PROJECT vs BXM_TEST_BOOT_DIR
|
|
98
|
+
|
|
99
|
+
| Env | Purpose |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `BXM_TEST_BOOT_PROJECT` | Root of a different project to use instead of cwd. Auto-set when BXM tests itself (points at the in-tree fixture under `src/test/fixtures/consumer-extension`). |
|
|
102
|
+
| `BXM_TEST_BOOT_DIR` | Absolute path of the directory holding `manifest.json` — short-circuits the discovery order entirely. Use for monorepo layouts or custom output dirs. |
|
|
103
|
+
|
|
104
|
+
## What happens when the extension can't load
|
|
105
|
+
|
|
106
|
+
Chromium silently rejects extensions with malformed manifests (no chrome-extension:// target ever appears, no error in console). The runner detects this and surfaces likely causes:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
✗ Boot aborted — Chromium loaded but no chrome-extension target appeared.
|
|
110
|
+
Likely cause: the extension failed to load. Common reasons:
|
|
111
|
+
- manifest.json missing required field (e.g. manifest_version: 3)
|
|
112
|
+
- default_locale is set but _locales/<locale>/messages.json is missing
|
|
113
|
+
- __MSG_*__ placeholders used without default_locale + _locales/
|
|
114
|
+
- referenced files (background.service_worker, content_scripts) don't exist on disk
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This catches real ship-breakers (broken locale references, missing bundles) before users see them.
|
|
118
|
+
|
|
119
|
+
## Skipping the build before boot tests
|
|
120
|
+
|
|
121
|
+
Boot tests assume `packaged/chromium/raw/` exists. They don't auto-trigger `npm run build` (that would slow the test loop). If the directory is missing, you get:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
○ boot tests skipped (no manifest.json found in any of:
|
|
125
|
+
/path/to/project/packaged/chromium/raw
|
|
126
|
+
/path/to/project/dist
|
|
127
|
+
— run `npm run build` first to produce packaged/chromium/raw/)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
In CI, run build then test in separate steps so failures are isolated.
|
|
131
|
+
|
|
132
|
+
## Why this exists
|
|
133
|
+
|
|
134
|
+
Build-layer tests can verify "the manifest source is well-formed." Background-layer tests can verify "BXM's framework code works inside a SW." But neither catches "the consumer's actual pipeline assembles into a Chrome-loadable extension." Boot tests do.
|
|
135
|
+
|
|
136
|
+
In BXM's own self-tests, the boot layer points at a hand-authored fixture extension (`src/test/fixtures/consumer-extension/`) — a known-good minimal MV3 extension. That validates the framework's boot runner is working; consumer projects then point it at their own packaged output to validate THEIR pipeline.
|
|
137
|
+
|
|
138
|
+
## See also
|
|
139
|
+
|
|
140
|
+
- [test-framework.md](test-framework.md) — overall harness, layers, ctx, expect API
|
|
141
|
+
- [environment-detection.md](environment-detection.md) — `Manager.isTesting()` and friends
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# Test Framework
|
|
2
|
+
|
|
3
|
+
Built-in test framework for both BXM itself and consumer projects. Jest-like assertion syntax (`expect(actual).toBe(expected)`), four layers, BEM/EM-style output.
|
|
4
|
+
|
|
5
|
+
## Running tests
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx bxm test # runs framework + project suites
|
|
9
|
+
npx bxm test --layer build # only build-layer suites (plain Node, fast)
|
|
10
|
+
npx bxm test --layer background # only background-layer suites (real MV3 SW)
|
|
11
|
+
npx bxm test --layer view # only view-layer suites (popup/options/sidepanel)
|
|
12
|
+
npx bxm test --layer boot # only boot-layer suites (real consumer extension)
|
|
13
|
+
npx bxm test --filter "messaging" # only suites/tests whose name contains "messaging"
|
|
14
|
+
npx bxm test --extended # run extended suites against REAL external services (Firebase, etc.) — normal mode skips them in-source, never mocks them
|
|
15
|
+
TEST_EXTENDED_MODE=true npx bxm test # same as --extended (the shared, unprefixed env var across BEM/BXM/UJM/EM)
|
|
16
|
+
npx bxm test --reporter json # pretty output + machine-readable {"event":"summary",...} line
|
|
17
|
+
BXM_TEST_DEBUG=1 npx bxm test # see Chromium/SW stderr (otherwise drained silently)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
In BXM itself, `npm test` does the same.
|
|
21
|
+
|
|
22
|
+
All test output is also teed (ANSI-stripped) to `<projectRoot>/logs/test.log`, truncated fresh on each run — same pattern as EM's `test.log` and BEM's `test.log`. Grep it after a run instead of scrolling terminal output.
|
|
23
|
+
|
|
24
|
+
### Selecting which tests run
|
|
25
|
+
|
|
26
|
+
`npx mgr test` takes two independent selectors that compose:
|
|
27
|
+
|
|
28
|
+
1. **The positional target** selects which test **files** run, by source + path.
|
|
29
|
+
2. **`--filter=<substring>`** matches test **names/descriptions** within the already-selected files.
|
|
30
|
+
|
|
31
|
+
#### Positional target — select files by source + path
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Everything — framework + project suites
|
|
35
|
+
npx mgr test
|
|
36
|
+
|
|
37
|
+
# ONLY project tests (all of them)
|
|
38
|
+
npx mgr test project:
|
|
39
|
+
|
|
40
|
+
# Only project tests matching a path
|
|
41
|
+
npx mgr test project:custom-test
|
|
42
|
+
|
|
43
|
+
# ONLY framework tests (mgr: is the universal cross-framework alias)
|
|
44
|
+
npx mgr test mgr:
|
|
45
|
+
|
|
46
|
+
# ONLY framework tests (BXM-specific aliases — equivalent to mgr:)
|
|
47
|
+
npx mgr test bxm:
|
|
48
|
+
npx mgr test framework:
|
|
49
|
+
|
|
50
|
+
# Framework tests matching a path
|
|
51
|
+
npx mgr test mgr:build/config
|
|
52
|
+
npx mgr test bxm:build/config
|
|
53
|
+
|
|
54
|
+
# Bare path (no prefix) — BOTH sources, matched by path
|
|
55
|
+
npx mgr test build/config
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The source prefix is standardized across all four OMEGA frameworks:
|
|
59
|
+
|
|
60
|
+
| Target | Selects |
|
|
61
|
+
|---|---|
|
|
62
|
+
| *(none)* | Everything — framework + project suites |
|
|
63
|
+
| `project:` | ONLY project tests (all of them) |
|
|
64
|
+
| `project:<path>` | Only project tests matching `<path>` |
|
|
65
|
+
| `mgr:` | ONLY framework tests (`mgr:` is the universal alias for "the manager's own tests") |
|
|
66
|
+
| `bxm:` / `framework:` | ONLY framework tests (BXM-specific aliases, equivalent to `mgr:`) |
|
|
67
|
+
| `mgr:<path>` / `bxm:<path>` | Framework tests matching `<path>` |
|
|
68
|
+
| `<path>` (bare) | BOTH sources, matched by `<path>` |
|
|
69
|
+
|
|
70
|
+
A source-prefixed target excludes the other source entirely; the path part (if any) matches by relative path prefix (relative to each source's `test/` root).
|
|
71
|
+
|
|
72
|
+
#### `--filter` — match test names/descriptions
|
|
73
|
+
|
|
74
|
+
`--filter` is **orthogonal** to the positional target: it matches a substring against test **names/descriptions** within the files the target already selected. They compose.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# All suites, but only tests whose name contains "messaging"
|
|
78
|
+
npx mgr test --filter "messaging"
|
|
79
|
+
|
|
80
|
+
# Project files only, then only tests named "*storage*" within them
|
|
81
|
+
npx mgr test project: --filter "storage"
|
|
82
|
+
|
|
83
|
+
# Combine the target with extended mode
|
|
84
|
+
TEST_EXTENDED_MODE=true npx mgr test build/config
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Layers
|
|
88
|
+
|
|
89
|
+
| Layer | Runs in | Use for |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `build` | Plain Node, fast (~ms) | `Manager.getConfig/getManifest/getPackage`, CLI alias resolution, schema/manifest validation, build helpers, `lib/*.js` regex maps + utilities |
|
|
92
|
+
| `background` | Real MV3 service worker via Puppeteer + CDP | Background boot sequence, Firebase auth wiring, messaging listeners, `chrome.runtime.onMessage` handlers |
|
|
93
|
+
| `view` | Chromium tab loading harness extension's popup.html / options.html / sidepanel.html | DOM bindings, Manager surface, web-manager integration, popup ↔ background messaging |
|
|
94
|
+
| `boot` | Real headless Chromium with the **consumer's** `packaged/<browser>/raw/` loaded as unpacked | End-to-end smoke: does the consumer's actual extension boot? Manifest validates? SW comes up? Popup renders? |
|
|
95
|
+
|
|
96
|
+
`all` (default) runs build → background → view → boot.
|
|
97
|
+
|
|
98
|
+
## NEVER mock — test against the real harness
|
|
99
|
+
|
|
100
|
+
Every layer hands your test the **real** runtime, never a hand-rolled fake:
|
|
101
|
+
|
|
102
|
+
- **No `mockManager`, no fake `chrome`/`browser` objects, no stubbed background/popup contexts.** `background`-layer tests run inside a real MV3 service worker with the real `chrome.*` API; `view`-layer tests run inside a real Chromium tab with the real DOM and real `chrome.runtime` messaging; `boot`-layer tests load the consumer's real packaged extension. Use what the harness gives you (`ctx`, `ctx.manager`, `ctx.page`, the `inspect` callback's `{ extension, page }`, and the browser globals `chrome` / `document` / `window`) — do not reconstruct any of it.
|
|
103
|
+
- **Pure functions (zero I/O) are the only thing you call directly.** A regex map in `lib/*.js`, a string formatter, a config-shape validator — `require` it and assert on its output in a `build`-layer test. That is not mocking; it is calling a pure function. Anything that touches real I/O (storage, messaging, the SW lifecycle, the DOM, the network) runs against the real harness, not a substitute.
|
|
104
|
+
|
|
105
|
+
### Real external APIs are GATED, NOT mocked
|
|
106
|
+
|
|
107
|
+
Tests that hit a real external service (Firebase, push, any network call) live in **extended suites** and are gated behind extended mode (`npx bxm test --extended` or `TEST_EXTENDED_MODE=true`):
|
|
108
|
+
|
|
109
|
+
- **Normal mode** (`npx bxm test`) **SKIPS** these calls **in-source** — guard them with `ctx.skip(reason)` (or an early return) so the test no-ops when extended mode is off. The external API is **skipped in-source, NOT mocked.** Never stand up a fake Firebase / fake fetch to make a normal-mode run go green.
|
|
110
|
+
- **Extended mode** (`npx bxm test --extended`) runs the same code against the **real** service.
|
|
111
|
+
- **Anything an extended test creates externally MUST be cleaned up by the test** — delete the doc/user/record it created (use the suite/group `cleanup: async (ctx) => { ... }` hook, which runs after the last test). Leave no residue in the real backend.
|
|
112
|
+
|
|
113
|
+
### Extended mode (`TEST_EXTENDED_MODE`)
|
|
114
|
+
|
|
115
|
+
Extended mode is the opt-in for tests that hit REAL external services (Firebase via web-manager, push, any network call from the background SW / popup / content scripts) instead of skipping them.
|
|
116
|
+
|
|
117
|
+
- **Skipped by default.** `npx bxm test` runs fast and offline-safe — external calls no-op in-source.
|
|
118
|
+
- **Opt in** with `npx mgr test --extended` (CLI shorthand) or `TEST_EXTENDED_MODE=true npx mgr test` (env var). `TEST_EXTENDED_MODE=1` is also accepted.
|
|
119
|
+
- **Shared, unprefixed name across BEM/BXM/UJM/EM.** All four OMEGA frameworks read the SAME `TEST_EXTENDED_MODE` env var (the canonical name is BEM's) — no `BXM_`-prefixed variant.
|
|
120
|
+
- **Propagates to every spawned test environment.** The command sets `process.env.TEST_EXTENDED_MODE = 'true'`, which is visible to the in-process Node runner and inherited by Puppeteer's Chromium (background / view / boot layers) since `puppeteer.launch()` inherits `process.env`.
|
|
121
|
+
- **The warning prints.** When on, the command logs `Test mode: extended (real external APIs)` plus a `⚠️` banner (teed to `logs/test.log`); when off it logs `normal (external APIs skipped)`.
|
|
122
|
+
- **Tests gate on `process.env.TEST_EXTENDED_MODE`.** Guard external-service tests with `if (process.env.TEST_EXTENDED_MODE !== 'true') ctx.skip('extended mode off');` (or an early return) so they no-op in normal mode.
|
|
123
|
+
|
|
124
|
+
### The ONLY two exceptions where a narrow stub is allowed
|
|
125
|
+
|
|
126
|
+
Mock **nothing** by default. There are exactly two cases where the real dependency genuinely cannot run in the test environment — and even then, stub the *smallest possible seam* (one method / one module), restore it immediately, and comment *why*:
|
|
127
|
+
|
|
128
|
+
1. **A side effect that would destroy the test run itself.** If invoking the real thing would kill or corrupt the harness — a process-exit, a destructive clean/wipe, or a *recursive re-invocation of a CLI command* (running the real `test`/`clean`/`setup` command from inside a test re-enters the runner) — you may stub *that one module/call* to a no-op, assert the dispatch logic, then restore. (Example: `cli.test.js` stubs the real command modules so testing CLI dispatch doesn't actually run them.)
|
|
129
|
+
2. **A real dependency the test environment can't provide.** When the real object only exists from infra you can't stand up in a `build`-layer unit test, a unit test may hand a minimal stub to verify a narrow side effect — but a real-harness layer (`background`/`view`/`boot`) MUST still cover the wired path where one exists.
|
|
130
|
+
|
|
131
|
+
If you can run it for real, you must. These exceptions are not a license to unit-test in isolation when a real-harness layer would work.
|
|
132
|
+
|
|
133
|
+
## Test coverage — every surface gets a test (HARD RULE)
|
|
134
|
+
|
|
135
|
+
A feature is not done when it works — it's done when every surface it exposes is covered in the layer that owns that surface:
|
|
136
|
+
|
|
137
|
+
| Coverage | Layer | Proves |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| **Logic** | `build` / `background` | The feature's functions do the right thing when called directly (real Manager, real `chrome.*`, real storage/messaging) |
|
|
140
|
+
| **UI** | `view` | The feature's interface is WIRED — a real event on the real DOM triggers the behavior and the visible result appears |
|
|
141
|
+
| **End-to-end** | `boot` | The feature survives in the consumer's actual packaged extension (extend the boot suite's `inspect` assertions) |
|
|
142
|
+
|
|
143
|
+
**Skipping a layer is the exception, not the default.** A layer may be skipped ONLY when the feature genuinely has no surface there — a pure build-time utility has no UI; a CSS-only tweak has no logic to call. Convenience is never a reason: "the logic test already covers it" does NOT excuse the UI test — logic tests prove the logic, UI tests prove the wiring (a button can come unhooked while every logic test stays green), boot tests prove the packaging. When in doubt, write the test.
|
|
144
|
+
|
|
145
|
+
## `BXM_TEST_MODE=true` — the canonical "we're in tests" signal
|
|
146
|
+
|
|
147
|
+
Both BXM test runners set `BXM_TEST_MODE=true` in spawned child envs. That powers `manager.isTesting()` (and `Manager.isTesting()` static) — the cross-context helper anything in BXM/consumer code should check when behavior needs to differ in tests. See [environment-detection.md](environment-detection.md).
|
|
148
|
+
|
|
149
|
+
Consumers writing their own tests get this automatically when running through `npx bxm test`. To set it manually in another runner:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
"test": "BXM_TEST_MODE=true vitest"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Test discovery
|
|
156
|
+
|
|
157
|
+
- **Framework defaults**: `<BXM>/dist/test/suites/**/*.js`
|
|
158
|
+
- **Consumer suites**: `<cwd>/test/**/*.js`
|
|
159
|
+
|
|
160
|
+
Directories starting with `_` are ignored. Files load alphabetically.
|
|
161
|
+
|
|
162
|
+
**Framework's boot suites are scoped to BXM self-test runs only.** When a consumer runs `npx bxm test`, the framework's `dist/test/suites/boot/**` is excluded from discovery (those tests assert on BXM's internal fixture extension). Consumers write their own boot tests under `<cwd>/test/boot/`. See [test-boot-layer.md](test-boot-layer.md).
|
|
163
|
+
|
|
164
|
+
## `test/_init.js` — pre-test lifecycle hook
|
|
165
|
+
|
|
166
|
+
The runner loads an optional `test/_init.js` from **both** test roots — the framework (`<BXM>/test/_init.js`) and the consumer project (`<cwd>/test/_init.js`) — and runs it **once, before any suite** (it is NOT itself run as a test; the `_`-prefix keeps it out of discovery). Mirrors the same hook in BEM/EM/UJM so all four frameworks share one shape.
|
|
167
|
+
|
|
168
|
+
The module **must export a function** — `module.exports = (ctx) => ({ ... })` — called with `{ projectRoot }` and returning the hook object. It may declare:
|
|
169
|
+
|
|
170
|
+
- `async setup({ projectRoot })` — runs once before the suites, e.g. to scaffold a fixture file the boot layer needs.
|
|
171
|
+
|
|
172
|
+
There is **no `cleanup` hook** and **no `accounts` field** (unlike BEM — these frameworks have no auth/user system): tests clean up after themselves, so there is nothing project-level to tear down.
|
|
173
|
+
|
|
174
|
+
```javascript
|
|
175
|
+
// <cwd>/test/_init.js
|
|
176
|
+
const fs = require('fs');
|
|
177
|
+
const path = require('path');
|
|
178
|
+
|
|
179
|
+
module.exports = ({ projectRoot }) => ({
|
|
180
|
+
async setup() {
|
|
181
|
+
// Seed any fixture a suite needs before it runs.
|
|
182
|
+
fs.mkdirSync(path.join(projectRoot, '.temp'), { recursive: true });
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Test file shapes
|
|
188
|
+
|
|
189
|
+
Three forms — pick whichever fits.
|
|
190
|
+
|
|
191
|
+
### Suite (sequential, share state, stop on first failure)
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
module.exports = {
|
|
195
|
+
type: 'suite',
|
|
196
|
+
layer: 'background',
|
|
197
|
+
description: 'storage round-trip',
|
|
198
|
+
cleanup: async (ctx) => { /* runs after the last test */ },
|
|
199
|
+
tests: [
|
|
200
|
+
{
|
|
201
|
+
name: 'set returns without throwing',
|
|
202
|
+
run: async (ctx) => {
|
|
203
|
+
await chrome.storage.local.set({ k: 'v' });
|
|
204
|
+
ctx.expect(true).toBe(true);
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: 'get returns the just-set value',
|
|
209
|
+
run: async (ctx) => {
|
|
210
|
+
const out = await chrome.storage.local.get('k');
|
|
211
|
+
ctx.expect(out.k).toBe('v');
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Tests share `ctx.state` across the suite. If one fails, remaining tests are skipped (`stopOnFailure: false` to disable).
|
|
219
|
+
|
|
220
|
+
### Group (sequential, share state, run all regardless of failures)
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
module.exports = {
|
|
224
|
+
type: 'group',
|
|
225
|
+
layer: 'build',
|
|
226
|
+
description: 'config defaults',
|
|
227
|
+
tests: [ /* same shape as suite */ ],
|
|
228
|
+
};
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Standalone (single test per file)
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
module.exports = {
|
|
235
|
+
layer: 'build',
|
|
236
|
+
description: 'manifest_version is 3',
|
|
237
|
+
run: (ctx) => {
|
|
238
|
+
const m = Manager.getManifest();
|
|
239
|
+
ctx.expect(m.manifest_version).toBe(3);
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Array form (treated as a group)
|
|
245
|
+
|
|
246
|
+
```js
|
|
247
|
+
module.exports = [
|
|
248
|
+
{ name: 'test 1', run: (ctx) => { /* ... */ } },
|
|
249
|
+
{ name: 'test 2', run: (ctx) => { /* ... */ } },
|
|
250
|
+
];
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## The `ctx` object
|
|
254
|
+
|
|
255
|
+
Every `run` / `cleanup` callback receives `ctx`:
|
|
256
|
+
|
|
257
|
+
- `ctx.expect` — Jest-compatible assertion library
|
|
258
|
+
- `ctx.state` — shared object across tests in a suite/group
|
|
259
|
+
- `ctx.skip(reason)` — throw to skip the current test at runtime
|
|
260
|
+
- `ctx.layer` — current layer name (`'build' | 'background' | 'view' | 'boot'`)
|
|
261
|
+
- `ctx.manager` — present on background-layer tests (the framework Manager instance)
|
|
262
|
+
- `ctx.page` — present on view-layer tests (the loaded tab's window)
|
|
263
|
+
|
|
264
|
+
Boot-layer tests use `inspect: async ({ extension, page, expect, projectRoot }) => { ... }` instead of `run`. See [test-boot-layer.md](test-boot-layer.md).
|
|
265
|
+
|
|
266
|
+
## `expect()` matchers
|
|
267
|
+
|
|
268
|
+
Same Jest-compatible surface across all layers:
|
|
269
|
+
|
|
270
|
+
```js
|
|
271
|
+
ctx.expect(actual).toBe(expected); // strict ===
|
|
272
|
+
ctx.expect(actual).toEqual(expected); // deep equality
|
|
273
|
+
ctx.expect(actual).toBeTruthy() / .toBeFalsy()
|
|
274
|
+
ctx.expect(actual).toBeDefined() / .toBeUndefined()
|
|
275
|
+
ctx.expect(actual).toBeNull()
|
|
276
|
+
ctx.expect(actual).toContain(item); // string or array
|
|
277
|
+
ctx.expect(actual).toHaveProperty('key')
|
|
278
|
+
ctx.expect(actual).toMatch(/regex/)
|
|
279
|
+
ctx.expect(actual).toBeInstanceOf(Class)
|
|
280
|
+
ctx.expect(actual).toBeGreaterThan(n) / .toBeLessThan(n)
|
|
281
|
+
await ctx.expect(fn).toThrow(/regex/) // async — fn may be async
|
|
282
|
+
ctx.expect(actual).not.toBe(expected) // negation: every matcher
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Consumer pattern — use the public Manager API
|
|
286
|
+
|
|
287
|
+
Don't `require('json5')` or other transitive BXM deps directly from consumer tests — they're not in your `package.json` and the resolution path is fragile. Instead use BXM's public API:
|
|
288
|
+
|
|
289
|
+
```js
|
|
290
|
+
const Manager = require('browser-extension-manager/build');
|
|
291
|
+
|
|
292
|
+
// Parsed JSON5 — same logic the framework uses internally
|
|
293
|
+
const config = Manager.getConfig();
|
|
294
|
+
const manifest = Manager.getManifest();
|
|
295
|
+
|
|
296
|
+
// Borrow any of BXM's bundled deps without listing them yourself
|
|
297
|
+
const JSON5 = Manager.require('json5');
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
This is the same pattern EM and BEM consumers use — assert on framework API output rather than re-implementing parsing/loading in every test.
|
|
301
|
+
|
|
302
|
+
## Build-layer example
|
|
303
|
+
|
|
304
|
+
```js
|
|
305
|
+
// test/build/config.test.js
|
|
306
|
+
const Manager = require('browser-extension-manager/build');
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
type: 'suite',
|
|
310
|
+
layer: 'build',
|
|
311
|
+
description: 'config has required brand fields',
|
|
312
|
+
tests: [
|
|
313
|
+
{
|
|
314
|
+
name: 'brand.id is set',
|
|
315
|
+
run: (ctx) => {
|
|
316
|
+
ctx.expect(Manager.getConfig().brand.id).toBeTruthy();
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: 'firebaseConfig.projectId matches brand.id',
|
|
321
|
+
run: (ctx) => {
|
|
322
|
+
const cfg = Manager.getConfig();
|
|
323
|
+
ctx.expect(cfg.firebaseConfig.projectId).toBe(cfg.brand.id);
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
};
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Background-layer example
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
// test/background/messaging.test.js
|
|
334
|
+
module.exports = {
|
|
335
|
+
type: 'suite',
|
|
336
|
+
layer: 'background',
|
|
337
|
+
description: 'chrome.runtime.* surface in real SW',
|
|
338
|
+
tests: [
|
|
339
|
+
{
|
|
340
|
+
name: 'chrome.runtime.id is a non-empty string',
|
|
341
|
+
run: async (ctx) => {
|
|
342
|
+
ctx.expect(typeof chrome.runtime.id).toBe('string');
|
|
343
|
+
ctx.expect(chrome.runtime.id.length).toBeGreaterThan(0);
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: 'storage.local round-trip',
|
|
348
|
+
run: async (ctx) => {
|
|
349
|
+
await chrome.storage.local.set({ k: 'v' });
|
|
350
|
+
const out = await chrome.storage.local.get('k');
|
|
351
|
+
ctx.expect(out.k).toBe('v');
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## View-layer example
|
|
359
|
+
|
|
360
|
+
```js
|
|
361
|
+
// test/view/popup.test.js
|
|
362
|
+
module.exports = {
|
|
363
|
+
type: 'suite',
|
|
364
|
+
layer: 'view',
|
|
365
|
+
context: 'popup', // popup | options | sidepanel — which HTML to open
|
|
366
|
+
description: 'popup DOM + chrome surface',
|
|
367
|
+
tests: [
|
|
368
|
+
{
|
|
369
|
+
name: 'document body has data-bxm-context="popup"',
|
|
370
|
+
run: async (ctx) => {
|
|
371
|
+
ctx.expect(document.body.dataset.bxmContext).toBe('popup');
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: 'popup ↔ background messaging round-trip',
|
|
376
|
+
run: async (ctx) => {
|
|
377
|
+
const reply = await chrome.runtime.sendMessage({ type: 'bxm:test:ping' });
|
|
378
|
+
ctx.expect(reply.pong).toBe(true);
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
};
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
For boot-layer (`inspect: async ({ extension, page, expect }) => { ... }`) tests, see [test-boot-layer.md](test-boot-layer.md).
|
|
386
|
+
|
|
387
|
+
## How browser-layer tests are shipped to the SW/tab
|
|
388
|
+
|
|
389
|
+
MV3 service workers have a strict CSP that forbids `eval` / `new Function` / `new AsyncFunction`. So we can't rebuild test functions from a string inside the SW.
|
|
390
|
+
|
|
391
|
+
Instead: each test's source is **baked as a literal async-function expression** directly into the payload at runner build-time, then evaluated as top-level Runtime.evaluate (CDP-exempt from CSP). No inner `eval` happens inside the SW. Tests communicate results back via `console.log('__BXM_TEST__' + JSON.stringify(evt))`, which the Node-side runner parses via CDP `Runtime.consoleAPICalled`.
|
|
392
|
+
|
|
393
|
+
You don't have to think about this — write tests in normal JS — but it's why test bodies must be **self-contained**: they can't close over their file's module scope. Use `ctx`, `expect`, `state`, and the browser globals (`chrome`, `document`, `window`) only.
|
|
394
|
+
|
|
395
|
+
## Why a custom harness instead of Jest / Vitest?
|
|
396
|
+
|
|
397
|
+
Browser-context code (background SW, popup DOM, content script) only runs inside Chromium. This is exactly why BXM does not let you mock: a faked `chrome.runtime` (Jest's jsdom can't reproduce it faithfully) or a stubbed API (`webextension-polyfill` provides one, but it doesn't catch real SW lifecycle bugs) passes tests while shipping broken extensions. Puppeteer gives a real Chromium with real `chrome.*` APIs, so the harness is the real thing — not a substitute you assert against. See [NEVER mock](#never-mock--test-against-the-real-harness).
|
|
398
|
+
|
|
399
|
+
Same trade-off EM ran into with Electron — tests must run inside the real runtime, so the framework owns the runner.
|
|
400
|
+
|
|
401
|
+
## See also
|
|
402
|
+
|
|
403
|
+
- [test-boot-layer.md](test-boot-layer.md) — boot layer deep-dive (loads consumer's actual packaged extension)
|
|
404
|
+
- [environment-detection.md](environment-detection.md) — `Manager.isTesting()` / `isDevelopment()` / etc.
|
package/docs/themes.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Themes
|
|
2
|
+
|
|
3
|
+
BXM ships two themes plus a template for new ones. Themes vendor their own SCSS + JS + Bootstrap-compatible variable system.
|
|
4
|
+
|
|
5
|
+
## Available themes
|
|
6
|
+
|
|
7
|
+
| Theme | Source | What it provides |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `bootstrap` | [src/assets/themes/bootstrap/](../src/assets/themes/bootstrap/) | Pure Bootstrap 5.3+. Use when you want unopinionated Bootstrap. |
|
|
10
|
+
| `classy` | [src/assets/themes/classy/](../src/assets/themes/classy/) | Bootstrap 5 + custom design system (colors, typography, components). The "branded" theme. |
|
|
11
|
+
| `_template` | [src/assets/themes/_template/](../src/assets/themes/_template/) | Template for creating new themes. Underscore prefix excludes it from production builds. |
|
|
12
|
+
|
|
13
|
+
## Activating a theme
|
|
14
|
+
|
|
15
|
+
Set in `config/browser-extension-manager.json`:
|
|
16
|
+
|
|
17
|
+
```jsonc
|
|
18
|
+
{
|
|
19
|
+
theme: {
|
|
20
|
+
id: 'classy', // 'bootstrap' | 'classy' | '<your-theme>'
|
|
21
|
+
appearance: 'dark', // 'dark' | 'light' (optional — drives {{ theme.appearance }})
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Webpack's `__theme__` alias resolves to `src/assets/themes/<id>/` so consumer JS can do `import '__theme__/_theme.js'` and get the right theme's entry point. SCSS gets the same via the `theme` load-path entry (see [css.md](css.md)).
|
|
27
|
+
|
|
28
|
+
## Theme structure
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
src/assets/themes/<theme-id>/
|
|
32
|
+
├── _config.scss # Theme variables (with !default so consumers can override)
|
|
33
|
+
├── _theme.scss # Theme entry — @forward + @use
|
|
34
|
+
├── scss/ # Theme-specific SCSS (components, utilities)
|
|
35
|
+
└── js/ # Theme-specific JS (e.g. Bootstrap's modal/popper init)
|
|
36
|
+
└── _theme.js # Theme JS entry (exposes Bootstrap globals to window.bootstrap)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Creating a new theme
|
|
40
|
+
|
|
41
|
+
1. Copy `_template/` to a new directory: `cp -r src/assets/themes/_template src/assets/themes/my-theme`
|
|
42
|
+
2. Rename the directory (remove the `_` prefix — that's only for the template).
|
|
43
|
+
3. Customize `_config.scss` — variables like `$primary`, `$font-family-base`, etc.
|
|
44
|
+
4. Add theme-specific styles under `scss/`.
|
|
45
|
+
5. Update `_theme.scss` to forward your overrides.
|
|
46
|
+
6. Activate via `config/browser-extension-manager.json` → `theme.id: 'my-theme'`.
|
|
47
|
+
|
|
48
|
+
## Overriding theme variables
|
|
49
|
+
|
|
50
|
+
In a consumer's `src/assets/css/main.scss`:
|
|
51
|
+
|
|
52
|
+
```scss
|
|
53
|
+
// Override before @use to take effect
|
|
54
|
+
@use 'browser-extension-manager' as * with (
|
|
55
|
+
$primary: #5B47FB,
|
|
56
|
+
$secondary: #FFA500,
|
|
57
|
+
);
|
|
58
|
+
@use 'theme' as *;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`!default` flags on theme variables let `with (...)` overrides win.
|
|
62
|
+
|
|
63
|
+
## `{{ theme.appearance }}`
|
|
64
|
+
|
|
65
|
+
If `config.theme.appearance === 'dark'`, the HTML page template adds `class="dark"` (or `data-bs-theme="dark"`, depending on theme) to `<html>`. Theme SCSS can then key off `:root.dark` / `[data-bs-theme="dark"]` for dark-mode variants.
|
|
66
|
+
|
|
67
|
+
Consumer views can use `{{ theme.appearance }}` in their HTML to apply per-page tweaks. See [templating.md](templating.md).
|
|
68
|
+
|
|
69
|
+
## Why not Tailwind?
|
|
70
|
+
|
|
71
|
+
Themes are SCSS-first because BXM's roots are Bootstrap-based and most BXM consumers already use Bootstrap-style class names (`.btn`, `.card`, `.modal`). Tailwind requires a build step (PostCSS + content scanning) that would complicate the lean gulp pipeline. If a future theme wants Tailwind, drop it under `src/assets/themes/tailwind/` and wire its own build hook.
|
|
72
|
+
|
|
73
|
+
## See also
|
|
74
|
+
|
|
75
|
+
- [css.md](css.md) — SCSS load paths and component overrides
|
|
76
|
+
- [components.md](components.md) — component view + styles + script structure
|
|
77
|
+
- [build-system.md](build-system.md) — sass/webpack pipeline
|