Testing Extensions
This page covers the tools and techniques for testing your extensions during development.
Developer Mode: the edit-build-see loop
During development, run your extension unpacked -- directly from your project folder, rather than from a copy the app made.
- Open the app, run Open Preferences, and choose the Extensions section.
- Tick Developer Mode.
- Click Load unpacked extension… and select your extension's root folder (the one containing
extension.json). - Approve the requested permissions, then enable the extension.
After that, leave npm run watch running. The app watches two things and reloads when either changes:
extension.json- the bundle that
mainpoints at (dist/main.jsfor the scaffold)
It does not watch src/. Your sources are not what the host loads -- only the build output is -- so a rebuild is what triggers the reload. There is also a Reload button on each unpacked extension if you want to force it.
What a reload does and does not do:
| Behaviour | |
|---|---|
| Manifest changes | Re-read. New contributions and a new main path take effect. |
| New permissions | Not granted. You keep what you approved; remove and re-load to grant more. |
| Running extension | deactivate() runs, then the new code activates. |
| Panel UI files | Take effect after closing and reopening the panel, or reloading the iframe from DevTools. |
Extensions loaded this way show as Untrusted and Unpacked, and uninstalling one unregisters it without deleting your files.
Use "activationEvents": ["onStartup"] during development so your extension loads on every app launch, making the loop faster.
Installing a copy instead
Install from folder… and Install from .zip… copy the package into the app's extensions directory. That copy does not track your rebuilds, so it is the wrong tool while iterating -- use it when handing the extension to someone else.
Unit testing with @bible/extension-testing
The @bible/extension-testing package provides a mock implementation of the BibleExtensionAPI interface, letting you test your extension logic without running the full app.
Setup
Install the testing package as a dev dependency:
npm install --save-dev @bible/extension-testing
Writing tests
The mock API returns sensible defaults and records all calls so you can assert on them:
import { createMockApi } from '@bible/extension-testing';
import { activate, deactivate } from '../src/main.js';
describe('My Extension', () => {
let api;
beforeEach(() => {
api = createMockApi();
});
afterEach(async () => {
await deactivate();
});
test('registers a status bar item on activate', async () => {
await activate(api);
expect(api.ui.registerStatusBarItem).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.any(String),
text: expect.any(String),
})
);
});
test('fetches verse data when active verse changes', async () => {
// Configure mock return values
api.bible.getVerse.mockResolvedValue({
verseId: 43003016,
reference: 'John 3:16',
text: 'For God so loved the world...',
textPlain: 'For God so loved the world...',
});
await activate(api);
// Simulate an event
await api.bible.onDidChangeActiveVerse.fire({
verseId: 43003016,
module: 'kjv',
});
expect(api.bible.getVerse).toHaveBeenCalledWith(43003016);
});
});
Mock API features
The mock API:
- Records all method calls with their arguments (compatible with Jest and Vitest spy assertions).
- Lets you configure return values with
mockResolvedValue()andmockRejectedValue(). - Provides
fire()methods on event objects to simulate host-emitted events. - Enforces the same JSON-serializability constraints as the real API, helping you catch issues early.
Testing panels
For panel UI testing, use standard browser testing tools (Playwright, Puppeteer, or jsdom). The @bible/extension-ui SDK can be mocked in your test environment:
// Mock the extension-ui bridge in your panel tests
window.__extensionBridge = {
onMessage: (cb) => { window.__messageHandler = cb; },
postMessage: jest.fn(),
onThemeChanged: jest.fn(),
};
// Simulate a message from the worker
window.__messageHandler({ verseId: 43003016, text: 'Test verse' });
// Assert DOM updates
expect(document.getElementById('content').textContent).toContain('Test verse');
Smoke testing with bible-ext-smoke
Smoke testing exercises every hook your extension exports against a large corpus of representative inputs and verifies that each call returns a schema-valid result without throwing, timing out, breaching its declared permissions, or making unexpected network requests. It is smoke, not correctness -- the harness asks "does this hook return something valid?", not "is the answer right?". Use it as a pre-publish gate to catch crashes, serialization bugs, schema drift, and permission leaks before shipping.
Quick start
The create-extension scaffold adds a smoke script to your package.json:
npm run smoke
That runs bible-ext-smoke against the current directory. Exit code is 1 if any hook fails and 0 otherwise.
You can also invoke the CLI directly:
npx bible-ext-smoke ./path/to/extension
# or the multi-command entrypoint:
npx bible-ext smoke
What gets tested
The harness discovers every hook declared in your extension.json / exported from main, then invokes each one repeatedly with fixtures drawn from a built-in corpus. Defaults cover:
- Verse IDs -- ~97 canonical IDs spanning OT/NT, chapter boundaries, Psalms, and edge books.
- Verse ranges -- ~19 ranges including single-verse, cross-chapter, and cross-book.
- Reference strings -- ~60 strings mixing canonical ("John 3:16"), abbreviated ("Jn 3.16"), malformed, and Unicode forms.
- Dictionary keys -- Strong's numbers (Greek + Hebrew) and lemma strings.
- Section IDs, command args, event payloads -- representative shapes for book/commentary/command/event hooks.
- Storage + network fixtures -- seeded KV states and mock responses for permission-aware hooks.
Each hook runs once per applicable fixture. Endpoint hooks (those that require the host to call back into the extension) are currently skipped -- see What smoke testing does not catch below.
Failure taxonomy
Each invocation produces one of these outcomes:
| Outcome | Meaning |
|---|---|
pass | Hook returned a schema-valid value within the timeout. |
threw | Hook threw an exception. |
timeout | Hook did not resolve within --timeout ms (default 2000). |
invalid-return | Hook resolved, but the return value failed schema validation. |
permission-violation | Hook tried to use an API its manifest does not declare. |
unexpected-network | Hook attempted a network request not covered by fixtures or manifest. |
skip | Hook could not be invoked (e.g. endpoint hooks pending reverse-RPC). Never counted as a failure. |
Customizing the corpus
Drop a smoke.corpus.json at your extension root and the harness will auto-discover and validate it (via validateUserCorpus), then merge it over the defaults with mergeCorpus. Schema:
{
"mode": "extend",
"verseIds": [43003016, 45008028],
"referenceStrings": { "append": ["Jn iii 16", "John III.16"] },
"dictionaryKeys": { "replace": ["G25", "G26"] },
"storage": [{ "id": "custom-state", "entries": [] }]
}
Merge semantics:
- Top-level
"mode"is"extend"(default) or"replace". It controls how bare arrays are merged: extend appends to the defaults, replace swaps them out entirely. - Per-field
{ "append": [...] }always appends to the defaults, regardless of top-level mode. - Per-field
{ "replace": [...] }always replaces the defaults for that field, regardless of top-level mode. - Fields you omit are left at their defaults.
Use --corpus=<path> on the CLI to point at a corpus file outside the extension root (useful for CI matrices).
CLI reference
| Flag | Description |
|---|---|
[path] | Positional. Extension root to test. Defaults to cwd. |
--json | Emit the JSON envelope to stdout instead of the human-readable report. |
--ascii | Force ASCII-only output (no Unicode box characters). |
--include-values | Include captured input/output values in records. Off by default so logs stay small and free of PII. |
--timeout=<ms> | Per-invocation timeout. Default 2000. |
--corpus=<path> | Explicit path to a smoke.corpus.json override. |
--output=<path> | Write the report to a file instead of stdout. |
--help | Print usage. |
JSON output for CI
--json emits a stable envelope suitable for piping into a CI step:
{
"schemaVersion": 1,
"extensionId": "ext.my-publisher.hello-verse",
"extensionVersion": "0.1.0",
"totals": { "passed": 412, "failed": 0, "skipped": 3 },
"perHook": [ /* one entry per discovered hook */ ],
"records": [ /* one record per invocation */ ]
}
schemaVersionis pinned to1. Future breaking changes will bump this -- pin your CI parser to the version you tested against.- Exit code is
1ifftotals.failed > 0. Skipped hooks never fail the run. - Values are stripped from
recordsunless--include-valuesis passed.
What smoke testing does not catch
The single most important limitation, because it is the one that produces "it passed every test and broke on install":
:::danger The harness runs your code in Node. The app does not.
bible-ext-smoke loads your entry point into the Node process running the CLI. In that environment require('fs') resolves, process.env is populated, fetch and Buffer exist, and there are no realm memory or turn limits. An extension that depends on any of them passes smoke testing and fails the moment it is installed.
Your defences against this, in order of how early they catch the problem:
- The build tripwire.
platform: 'neutral'(see Packaging and Build) fails the build on any Node built-in import, yours or a dependency's. This catches the majority of cases before a test ever runs. - Do not write environment-sniffing code. In particular,
typeof require === 'function'is true in both environments -- inside the realm it is a stub that throws. See therequirenote. - Sideload and exercise it. This is the only complete check, because it is the real runtime.
The harness does support a realm mode that runs your bundle inside a real sandboxed realm, which reports these failures directly. It needs a realm implementation to drive, and that currently ships with the app rather than with @bible/extension-testing, so it is not yet reachable from the CLI.
:::
Other limitations:
- Smoke, not correctness. A pass means your hook returned a schema-valid shape, not that the content is right. Pair smoke with unit tests (above) and real-app verification.
- Endpoint hooks are skipped. These are hooks the host must call into -- a verse hover, a command handler. You register them by passing a function, and functions do not survive the RPC boundary, so the host holds a registration with no callable handler behind it. The harness reports them as
skiprather than inventing a pass. This is a gap in the API surface, not in the harness, and it applies to the running app too: reverse-RPC endpoint binding is still to come. - No performance benchmarking. The harness measures timeouts but does not report latency percentiles or regressions.
- No visual regression. Panel UIs are not rendered; UI correctness belongs in Playwright/Puppeteer suites.
Debugging with DevTools
Extension worker output
Your extension's console.log, console.warn, and console.error calls are captured by the host and forwarded to the app's main DevTools console. Open DevTools with View > Toggle Developer Tools or Ctrl+Shift+I (Cmd+Opt+I on macOS).
Worker logs are prefixed with the extension ID:
[ext.my-publisher.hello-verse] Active verse changed to John 3:16
[ext.my-publisher.hello-verse] Error: PermissionDeniedError: notes:write not granted
Panel iframe DevTools
To inspect a panel's iframe:
- Open the main DevTools.
- In the Console panel, use the execution context dropdown (top-left) to switch to the extension's iframe context (
ext-ui://<extensionId>). - You can now inspect the iframe's DOM, network requests, and console output.
Alternatively, right-click inside the panel and choose Inspect (if the context menu is available).
Common debugging scenarios
Extension does not activate:
- Check that your
activationEventsmatch what you expect. If usingonCommand:..., verify the command is being triggered. - Look for manifest validation errors in the DevTools console at startup.
- Verify the
mainpath in the manifest points to an existing file.
API calls fail with PermissionDeniedError:
- Confirm the required permission is in your manifest's
permissionsarray. - After adding a new permission, you must re-sideload the extension and re-approve permissions.
Panel shows a blank page:
- Check the DevTools console for CSP violations (e.g., inline scripts being blocked).
- Verify the
uiEntrypath is correct relative to the extension root. - Make sure all resources (JS, CSS, images) are bundled locally -- external URLs are blocked by the CSP.
RPC timeout errors:
- API calls have a 30-second timeout. If you are doing heavy work, consider using the
api.tasks.run()background task API instead. - Check that you are
await-ing promises correctly. A forgottenawaitcan cause the worker to appear unresponsive.
Log files
In development mode (npm run dev), extension logs go to the terminal where you launched the app. In the packaged app, they are written to:
| Platform | Path |
|---|---|
| Linux | ~/.config/bible-desktop-app/logs/main.log |
| macOS | ~/Library/Logs/bible-desktop-app/main.log |
| Windows | %USERPROFILE%\AppData\Roaming\bible-desktop-app\logs\main.log |
Extension-specific entries are tagged with the extension ID, making them easy to filter.