Skip to main content

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.

  1. Open the app, run Open Preferences, and choose the Extensions section.
  2. Tick Developer Mode.
  3. Click Load unpacked extension… and select your extension's root folder (the one containing extension.json).
  4. 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 main points at (dist/main.js for 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 changesRe-read. New contributions and a new main path take effect.
New permissionsNot granted. You keep what you approved; remove and re-load to grant more.
Running extensiondeactivate() runs, then the new code activates.
Panel UI filesTake 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.

tip

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() and mockRejectedValue().
  • 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:

OutcomeMeaning
passHook returned a schema-valid value within the timeout.
threwHook threw an exception.
timeoutHook did not resolve within --timeout ms (default 2000).
invalid-returnHook resolved, but the return value failed schema validation.
permission-violationHook tried to use an API its manifest does not declare.
unexpected-networkHook attempted a network request not covered by fixtures or manifest.
skipHook 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

FlagDescription
[path]Positional. Extension root to test. Defaults to cwd.
--jsonEmit the JSON envelope to stdout instead of the human-readable report.
--asciiForce ASCII-only output (no Unicode box characters).
--include-valuesInclude 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.
--helpPrint 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 */ ]
}
  • schemaVersion is pinned to 1. Future breaking changes will bump this -- pin your CI parser to the version you tested against.
  • Exit code is 1 iff totals.failed > 0. Skipped hooks never fail the run.
  • Values are stripped from records unless --include-values is 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:

  1. 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.
  2. 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 the require note.
  3. 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 skip rather 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:

  1. Open the main DevTools.
  2. In the Console panel, use the execution context dropdown (top-left) to switch to the extension's iframe context (ext-ui://<extensionId>).
  3. 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 activationEvents match what you expect. If using onCommand:..., verify the command is being triggered.
  • Look for manifest validation errors in the DevTools console at startup.
  • Verify the main path in the manifest points to an existing file.

API calls fail with PermissionDeniedError:

  • Confirm the required permission is in your manifest's permissions array.
  • 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 uiEntry path 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 forgotten await can 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:

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