--- name: ckeditor5-testing description: >- Testing CKEditor 5 plugins in the Trilium monorepo. Use when adding or reviewing unit tests for the packages/ckeditor5 aggregate (including its in-tree plugins under src/plugins/), debugging a failing test, or setting up a package's test runner. Covers the WebdriverIO browser-mode Vitest setup, the vitest.config.ts, testing against a real ClassicEditor, the model/view helpers imported from 'ckeditor5' (_setModelData / _getModelData / _getViewData and their {}/[] selection syntax), vi spies/mocks, idiomatic patterns for schema/conversion/command/UI tests, the pnpm --filter runner, and Trilium-specific conventions and gotchas. Complements the ckeditor5-plugin-development and writing-unit-tests skills. --- # CKEditor 5 testing (Trilium) Testing CKEditor 5 plugins in the **Trilium (TriliumNext Notes) monorepo**. Nearly everything lives in one package, `packages/ckeditor5`, with **tests co-located as `*.spec.ts` next to the source** — including every plugin under `src/plugins/`. It gates `src/**` at **100% coverage** on all four metrics, so every code change must ship with a test. ## Scope & sources This skill covers testing CKEditor 5 plugins in the **Trilium (TriliumNext Notes) monorepo** (`packages/ckeditor5`). The CKEditor 5 library is 48 or later. For general (non-CKEditor) Trilium testing, see the `writing-unit-tests` skill. **On versions:** these skills name **major versions only** ("48 or later"). Trilium tracks CKEditor 5 closely, so an exact pin written here would be stale within weeks — read the current one from `packages/ckeditor5/package.json`. ## When to use this skill Adding/reviewing unit tests for a plugin, debugging a failing test, or configuring a package's runner. For writing the feature itself, use the `ckeditor5-plugin-development` skill. For general Trilium testing (Preact components, jQuery widgets, server routes), use `writing-unit-tests`. ## The current setup at a glance - **Runner:** Vitest 4 or later. **No shared factory** — each package has its own `vitest.config.ts` built with `defineConfig` directly. - **One environment: WebdriverIO browser mode** (`@vitest/browser-webdriverio`, headless Chrome), used by `ckeditor5`. Real DOM and real layout, so `getBoundingClientRect()`, `elementFromPoint()` and pointer events behave as in a browser. Gates `src/**` coverage at 100% (lines/functions/branches/statements). This is **not Playwright**. - Trilium used to run some plugins on **happy-dom**; that is gone. If you are porting an old test, note the two differences that bite: happy-dom returned zeros from layout APIs (so measurement-dependent code silently "passed"), and it fired some DOM events synchronously where a real browser defers them — `
` fires `toggle` on a later task, for one. Synthetic events also need `cancelable: true` before `preventDefault()` means anything. - **Real editor, no test-editor factories.** Tests create a real `ClassicEditor` against a real DOM element (see below). There is **no** `ModelTestEditor`/`VirtualTestEditor`/`ClassicTestEditor` in Trilium — those live only in the upstream ckeditor5 monorepo's `tests/_utils`. - **Helpers from `'ckeditor5'`:** `_setModelData`, `_getModelData`, `_getViewData` are imported from the `ckeditor5` package. - **Test-file location:** **co-located `*.spec.ts`** next to the source, including inside plugin folders (`src/plugins//.spec.ts`), with vitest `include: ['src/**/*.spec.ts']`. `globals: true`. Coverage provider `v8`, `include: src/**` (test files themselves excluded from coverage). The aggregate keeps `allowExternal: false` so an imported sibling package cannot bleed into its report (see `references/running-and-config.md`). - **Imports** from `'ckeditor5'`; in-package source imports use a file extension. - **License key:** tests pass `licenseKey: 'GPL'` in the editor config. - **Every plugin folded into the aggregate carries its own tests**, and the 100% gate keeps it that way. Unreachable code is the usual reason the gate cannot be met — see `references/test-conventions.md` for how Trilium handles it. ## Running tests ```bash pnpm --filter @triliumnext/ckeditor5 test # the whole suite (from anywhere) # or, from the package dir: vitest run ``` Debug a browser-mode package with a visible browser: ```bash vitest --inspect-brk --no-file-parallelism --browser.headless=false ``` Root orchestration: `pnpm test:parallel` runs the light packages in parallel; `pnpm test:sequential` runs `ckeditor5` **sequentially** (browser resource limits), alongside the server. `pnpm test:all` runs both. Each package exposes `"test": "vitest"` and `"test:debug": "vitest --inspect-brk --no-file-parallelism --browser.headless=false"`. **When the downloaded browser cannot run** — on NixOS the Chrome for Testing build and chromedriver webdriverio fetches into `/tmp` are linked against libraries no store path provides and die on a missing `libxcb.so.1` — point the suite at a system pair instead: ```bash CHROME_BIN=/path/to/chromium CHROMEDRIVER_PATH=/path/to/chromedriver \ pnpm --filter @triliumnext/ckeditor5 test ``` `CHROMEDRIVER_PATH` is webdriverio's own variable and makes it spawn that driver on a free port; `CHROME_BIN` is read by the package's `vitest.config.ts` and passed as `goog:chromeOptions.binary`, which also stops the browser download (`setupPuppeteerBrowser` returns early for a string `binary`). The two versions must match at least in their major. `nix develop` sets both from `pkgs.chromium` and `pkgs.chromedriver`, so inside the dev shell the plain command works — **don't** start a driver by hand or write a local override config. Failed browser tests dump PNGs into a gitignored `__screenshots__` beside the spec; delete them afterwards. ## Anatomy of a test In the aggregate (`packages/ckeditor5`), use the shared **editor kit** — `createTestEditor()` from `test/editor-kit.ts` builds a real `ClassicEditor` (`licenseKey: 'GPL'`, auto-tracked) and the global `afterEach` in `test/setup.ts` (wired via `setupFiles`) destroys every tracked editor, so specs **don't** write their own editor-teardown `afterEach`: ```ts import { ClassicEditor, Essentials, Paragraph, _setModelData } from 'ckeditor5'; import { describe, it, expect, beforeEach } from 'vitest'; import { createTestEditor } from '../../test/editor-kit.js'; import MyPlugin from './myplugin.js'; describe( 'MyPlugin', () => { let editor: ClassicEditor; beforeEach( async () => { editor = await createTestEditor( [ Essentials, Paragraph, MyPlugin ] ); } ); it( 'loads the plugin', () => { expect( editor.plugins.get( MyPlugin ) ).toBeInstanceOf( MyPlugin ); } ); it( 'keeps the selection in a paragraph', () => { _setModelData( editor.model, 'foo[]bar' ); expect( editor.model.document.getRoot().getChild( 0 ).name ).toBe( 'paragraph' ); } ); } ); ``` Need the host element? It's `editor.sourceElement` (or `getEditorElement( editor )` from the kit). Some legacy specs still hand-roll the create/destroy scaffold (`document.createElement('div')` + `ClassicEditor.create(...)` + a teardown `afterEach`) — those are being migrated to `createTestEditor`. Conventions visible here and across the suite: - One top-level `describe` named after the unit, nested `describe`s for areas (`isEnabled`, `execute()`, …), small focused `it`s. - Create the editor in `beforeEach` (return the Promise or use `async`/`await` — Vitest awaits it). - Pass `licenseKey: 'GPL'` (the kit does this for you). List only the plugins the test needs (commands can also be instantiated directly, e.g. `new InsertMermaidCommand( editor )`). ## Asserting localized strings Trilium passes the English text itself as the message id (see the **`ckeditor5-plugin-development`** skill), so a test editor with no dictionary renders **the English text** — assert that literal: ```ts expect( button.label ).toBe( 'Collapsible block' ); ``` Never assert a `text-editor.…` key: that shape belonged to the retired host bridge, and a spec expecting one is testing a mechanism that no longer exists. To prove a string really is translatable rather than hardcoded, configure a dictionary. The entry must be keyed by language, and the leading `{}` is load-bearing — CKEditor merges `translations` with `reduce(merge)` and no initial value, so without the seed it mutates the first entry: ```ts editor = await createTestEditor( [ Essentials, Paragraph, Collapsible ], { translations: [ {}, { en: { dictionary: { 'Collapsible block': 'Bloc pliabil' } } } ] } ); expect( createButton( editor ).label ).toBe( 'Bloc pliabil' ); ``` `en` because that is the language the editor resolves under when none is configured. Getting the shape wrong (omitting the language key) throws inside `Locale.t` and fails every spec in the file, not just the one. For a helper that takes a translator parameter rather than an editor, pass `( message: string ) => message` — the same English-fallback behaviour, no editor needed. To observe *which* messages a plugin asked for, spy with `vi.spyOn( editor, 't' )`: `t` is an own property the editor assigns in its constructor, so the spy intercepts as long as the code reads `this.editor.t` per call. Install it before whatever triggers the render. ## Model/view test data `_setModelData()` / `_getModelData()` (and `_getViewData()`) stringify and parse the engine structures, with a special selection syntax: - `[]` — collapsed selection, **or** brackets around a range, anchored in an **element**. - `{}` — selection anchored inside a **text node** (e.g. `foo{}bar` / `f{oo}bar`). - Attributes render as `<$text bold="true">word`; elements as ``. ```ts _setModelData( model, 'foo[]bar' ); expect( _getModelData( model ) ).toEqual( 'foo[]bar' ); expect( _getViewData( editor.editing.view ) ).toEqual( '

foo{}bar

' ); ``` These are dev/test utilities only — never ship them in production code. ## Assertions & spies (Vitest) - Both **Jest-style** (`expect(x).toBe(y)`, `.toEqual()`, `.toBeInstanceOf()`, `.toHaveBeenCalledWith()`) and **Chai-style** (`expect(x).to.equal(y)`, `.to.be.false`, `.to.instanceOf()`) matchers work in Vitest. The existing Trilium tests mix both. There are **no** custom matchers — compare stringified model/view directly. - Spies/mocks via `vi`: `vi.spyOn( editor, 'execute' )`, `vi.fn()`, `vi.useFakeTimers()`. ```ts const spy = vi.spyOn( editor, 'execute' ); button.fire( 'execute' ); expect( spy ).toHaveBeenCalledWith( 'insertMermaid' ); ``` ## Stubbing the Trilium glue (`glob` / clipboard / jQuery `$`) Many in-aggregate plugins reference a global `glob` (the Trilium bridge typed in `src/augmentation.ts`), some hit `navigator.clipboard`, and some converters call jQuery `$(...)`. Use the globals kit (`test/globals-test-kit.ts`): `installGlobMock({…})` and `mockClipboard({…})` install the stub **and register their own teardown** (run by the global `afterEach` in `test/setup.ts`), and `$` is a global passthrough from `setup.ts` — so specs **don't** hand-roll `globalThis.glob` or delete anything. (Browser mode shares one page, so a leaked global would bleed into later specs.) See `references/patterns.md` for the recipe. ## Reference map | File | Use it for | |------|-----------| | `references/test-utilities.md` | Testing against a real `ClassicEditor` (lifecycle, `licenseKey: 'GPL'`), and the `_setModelData`/`_getModelData`/`_getViewData` helpers from `'ckeditor5'` + the `[]`/`{}` selection syntax. | | `references/patterns.md` | Idiomatic recipes per concern (schema, conversion round-trips, commands, UI, keystrokes, events, async), all against a real editor; the `glob`/clipboard/jQuery-`$` stubbing recipe (via the globals kit's `installGlobMock`/`mockClipboard`); note on the 100% coverage gate for browser-mode packages. | | `references/running-and-config.md` | The WebdriverIO `vitest.config.ts` shape, `pnpm --filter` commands, the debug command, `pnpm test:parallel`/`test:sequential` (ckeditor5 + math sequential), coverage thresholds. | | `references/test-conventions.md` | Trilium test **conventions & gotchas**: real-browser event timing, real-editor teardown, the both-assertion-styles note, unreachable code vs. the 100% gate, and the pointer to `writing-unit-tests`. | ## Quick review checklist When reviewing tests: editor created in `beforeEach` with `licenseKey: 'GPL'` and **destroyed** in `afterEach` (plus `editorElement.remove()`); model/view asserted via `_getModelData`/`_getViewData` with correct `[]`/`{}` selection syntax; spies via `vi`; behavior covered for collapsed **and** ranged selections and schema-disallowed contexts; new `src/` lines covered (100% gate), with any `/* v8 ignore */` carrying a comment that justifies why the code is unreachable.