NoteGenNOTEGEN.

Testing plugins

Test plugin lifecycle, permissions, notes, editor state, settings, storage, and UI calls in a deterministic in-process host.

@notegen/plugin-test is the behavior test double for NoteGen plugins. It implements the public PluginContext in a Node.js process so a test can control the workspace, permissions, notes, editor state, and time, then inspect read-only snapshots left by the plugin.

Use it with Vitest, Jest, another TypeScript test runner, or a plain Node.js script. It does not start NoteGen and does not validate archives or signatures.

Updated 2026-09-10: all four SDK packages are published on npm and the signed official marketplace is live; community submissions remain closed. API details describe the current development branch, so unreleased additions require the matching source version. Remote installation also requires a NoteGen build with plugin support and the production root public key.

Install

pnpm add -D @notegen/plugin-api @notegen/plugin-test

Keep it in development and test dependencies. Do not bundle @notegen/plugin-test into the plugin entry; NoteGen still loads only the single-file ESM produced by notegen-plugin build.

Minimal test

import { definePluginManifest, type PluginActivate } from "@notegen/plugin-api";
import { createPluginTestHost } from "@notegen/plugin-test";

const commandId = "com.example.hello.open";

const manifest = definePluginManifest({
  manifestVersion: 1,
  id: "com.example.hello",
  name: "Hello",
  version: "0.1.0",
  apiVersion: "^0.1.0",
  minAppVersion: "0.37.0",
  platforms: ["desktop"],
  entry: "dist/main.js",
  activationEvents: [`onCommand:${commandId}`],
  permissions: {},
  contributes: {
    commands: [{ id: commandId, title: "Say hello" }],
  },
});

const activate: PluginActivate = async (ctx) => {
  ctx.commands.handle(commandId, async () => {
    await ctx.ui.showNotice("Hello from the test host");
  });
};

const host = createPluginTestHost({ manifest });
await host.activate({ activate });
await host.executeCommand(commandId);

console.log(host.notices);
console.log(host.callHistory);

await host.deactivate();

One host activates exactly one plugin instance and cannot be activated again after deactivation. Create a new host for each test so lifecycle, registrations, and stored state do not leak between cases.

Deactivation rejects pending activation, command execution, and injected network requests with Cancelled; it does not wait for those handlers to finish. The underlying Node.js callbacks are not forcibly stopped, so plugin code should observe ctx.signal before its own asynchronous side effects. The test host also checks network permission again before returning an injected response: revoking the grant while a request is pending prevents a successful response.

createPluginTestHost options

const host = createPluginTestHost({
  manifest,
  permissions: {},
  workspace: { id: "workspace-1", name: "Test Workspace" },
  notes: [],
  folders: [],
  storage: { device: {}, workspace: {} },
  settings: {},
  editor: { active: null, selection: null, text: "" },
  messages: {},
  now: () => new Date(0),
});
OptionType and default behavior
manifestRequired PluginManifestV1; determines available commands, status items, settings, and permissions
permissionsOverrides grants by permission name; only manifest-declared permissions are accepted
workspaceOptional WorkspaceInfo; defaults to ID test-workspace and name Test Workspace
notesSeeds { path, content, id?, revision? }; when revision is omitted, a deterministic value is derived from the UTF-8 content's SHA-256
foldersSeeds folders, including empty folders with no notes; the workspace root always exists
storageSeeds device and workspace JSON KV areas and immediately checks their combined quota
settingsOverrides manifest setting defaults; values must match the declaration's type and range
editorSeeds the active editor, selection, and optional Markdown text
messagesKey-value text used by ctx.i18n.t
nowFunction returning the current Date; defaults to the Unix epoch on every call

The test host assumes the supplied manifest has passed author-side validation, but still uses it to constrain plugin behavior. To test invalid JSON or an invalid manifest, use Command-line tools instead of passing an invalid object to the test host.

Seed notes with the same content receive the same revision, and an explicitly supplied revision is preserved. This keeps results independent of seed order. To simulate an external update, still provide a new revision explicitly or use the corresponding event control.

The package root exports createPluginTestHost and the types PluginTestHost, PluginTestHostOptions, PluginTestCallName, PluginTestCall, PluginTestNote, PluginTestEditorState, PluginTestStorageSeed, PluginTestStorageSnapshot, PluginTestScopedPermissionGrant, PluginTestPermissionGrant, and SetActiveEditorOptions. It has no deep entry point intended for use outside the test process.

Permission grants

Manifest-declared permissions receive permissive grants by default. Override them at construction or change them during a test:

const host = createPluginTestHost({
  manifest,
  permissions: { "notes.read": false },
});

host.setPermission("notes.read", true);

Boolean true is an unrestricted convenience for concise tests; it is not the shape of a production grant record. Use a scoped grant to reproduce a real file or folder boundary:

const host = createPluginTestHost({
  manifest,
  permissions: {
    "notes.read": {
      granted: true,
      paths: ["Templates/example.md"],
    },
    "notes.create": {
      granted: true,
      paths: ["Journal"],
    },
  },
});

host.setPermission("notes.read", {
  granted: true,
  paths: ["Templates/weekly.md"],
});
Manifest scopeMatch behavior
workspace-fileMatches only the listed single file
workspace-filesMatches only the listed files
workspace-folderMatches the folder itself and all descendants

An empty paths array allows no path access. For workspace-folder, paths: [""] grants the whole workspace. A permission absent from the manifest is rejected and cannot be added temporarily through setPermission.

Read-only result snapshots

PropertyContents
manifestManifest supplied when the host was created
contextPluginContext passed to the plugin, also available for focused tests
activeWhether the plugin is in the active lifecycle state
callHistoryAPI call names and arguments ordered by sequence
noticesFinal text displayed by showNotice
statusBarLatest PluginStatusBarUpdate keyed by contribution ID
notesCurrent NoteSnapshot list sorted by path
settingsCurrent contributed setting values
storagedevice and workspace KV snapshots
permissionsCurrent boolean or scoped grant snapshots

Except for manifest and context, collection getters recreate and freeze their top-level arrays or records, so changing a top-level container cannot alter the host's internal collection. Nested values are not guaranteed to be deeply frozen. manifest is the original reference supplied when the host was created, and context is the live runtime object. Treat every property as read-only and do not mutate it in assertions. clearCallHistory() clears only the call records; it does not reset the plugin, notes, settings, or storage.

Test control methods

MethodPurpose
activate(module)Calls plugin activate and enters the active state
deactivate()Signals cancellation, calls optional deactivate, and clears registrations
executeCommand(commandId, argument?)Runs a declared command with a registered handler
setPermission(permission, grant)Grants, denies, or changes a path scope
setSetting(key, value)Validates and changes a contributed setting, then notifies listeners
setActiveEditor(editor, options?)Switches the active editor and optionally supplies selection and Markdown text
setEditorSelection(selection)Changes the current selection snapshot
emitEditorContentChange(event, text?)Emits a content event and optionally replaces later snapshot text
clearCallHistory()Clears the API call history

executeCommand returns NotFound for an undeclared ID. A declared command without a handler returns RuntimeFailure. Settings, status items, and permissions are also constrained by the manifest.

Test editor state and events

await host.setActiveEditor(
  {
    windowId: "main",
    editorId: "editor-1",
    documentId: "note-1",
    kind: "markdown",
    mode: "source",
    revision: 1,
    composing: false,
    size: { utf16Length: 5, bytes: 5, lines: 1 },
  },
  { text: "Hello", selection: null },
);

await host.emitEditorContentChange(
  {
    editorId: "editor-1",
    documentId: "note-1",
    revision: 2,
    composing: false,
    size: { utf16Length: 11, bytes: 11, lines: 1 },
  },
  "Hello world",
);

getTextSnapshot produces NotFound when there is no active editor or the editor ID differs. Only an expected-revision mismatch produces StaleRevision. Switching to another document ID does not reuse the previous text. A rejected setting or editor listener does not block other listeners or roll back a state change already applied by the host.

Test writes, views, and networking

The in-memory host implements notes.list/write/move/delete/onDidChange and editor.applyEdit; assert results through host.notes and host.callHistory. Note operations use these deterministic rules:

  • notes.list() is non-recursive by default and defaults to a limit of 200; listing a folder that does not exist returns NotFound.
  • When openOrCreate or write({ create: true }) actually creates a file, subscribers with read or list access receive a created event.
  • notes.move({ from, to }) succeeds without changing content, revision, or events when the two normalized paths are equal.
  • notes.write derives a deterministic content-hash revision from the written text.
  • Use openNotePaths and host.setOpenNotePaths(paths) to model notes open in any tab, pane, or separate window; file writes, moves, and deletes return EditorBusy. openOrCreate({ open: true, ... }) also marks its target as open.
  • surface: "editor-window" models a separate window, rejecting file mutations and open: true while keeping the editor API available. The test host does not simulate real window races or save queues.

Use host.emitNoteChange(event) and host.emitWorkspaceChange(event) for external changes. Declarative content is exposed as host.views, and the last dialog as host.dialog. The test host strictly validates declarative UI block shapes, unknown fields, count and byte limits, and rejects an action that references a command not declared by the manifest.

Networking returns UnavailableOnPlatform unless the test supplies networkFetch(request), and exact network-origins grants are still enforced. Before the handler runs, the request receives production-shaped safety checks for a public HTTPS hostname, no URL credentials or fragment, allowed methods, restricted headers, the body limit, and timeout normalization to 1–30 seconds. The handler response is checked for status, headers, and the 2 MiB body limit, and set-cookie is removed. The test double never performs a real request by itself.

Time, notices, and status bar

The default clock is fixed at the Unix epoch. Supply a controllable now when testing date-sensitive behavior or status update throttling:

let now = new Date("2026-01-01T00:00:00Z");
const host = createPluginTestHost({
  manifest,
  now: () => now,
});

now = new Date(now.getTime() + 100);

The test host implements these text and declaration constraints:

  • notices are truncated to 500 UTF-16 code units;
  • status text fields are truncated to 160 UTF-16 code units;
  • later updates to one status item within 100 ms are trailing-coalesced, and the last state is applied when the window ends;
  • the status item ID must be declared in the manifest.

Both the test host and production host use trailing coalescing. The test host still does not model exact browser and operating-system scheduling; assert the final state after the window, not the precise millisecond at which an intermediate frame appears.

Quotas and production-shaped failures

  • Note content and initial content are limited to 2 MiB.
  • Paths and idempotency keys follow production limits.
  • Device and workspace storage share a 256-key and 1 MiB quota.
  • Setting values must match the manifest type, options, and range.
  • Permission, command, status-bar, and editor-revision failures use stable PluginError.code values.

Assert the stable error.code; do not depend on the complete English message.

What it cannot replace

This package is not QuickJS and is not a security sandbox. Plugin code runs with the test process's full authority. It does not simulate DNS resolution, DNS-rebinding defenses, system proxies, or real HTTP/TLS. It also does not reproduce Worker/QuickJS isolation, CPU and memory limits, host-call timeouts, general call-rate limits, every cross-realm serialization detail, archive integrity, or signature verification. It is not safe for untrusted plugins.

Before release, you must still:

  1. run notegen-plugin build and notegen-plugin verify;
  2. import .notegen/package from NoteGen's Plugins → Developer page;
  3. check permission review, UI contributions, reload, and deactivation in the real desktop host.