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.
@@ -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