Triiiceratops Testing a plugin

Testing a plugin

Validate a plugin without a full application using @triiiceratops/plugin-sdk/testing and its real test viewer context.

@triiiceratops/plugin-sdk/testing lets you validate a plugin without a full application. It mounts your plugin against a test viewer context: a real, compiled ViewerState (real commands, real batched notifications) with recording doubles for the style, UI, and locale services and an injectable renderer stand-in that is absent by default. Only the harness is simulated; the state is real, so a passing test reflects production behavior.

The kit runs in a plain Vitest project with no Svelte tooling, because the headless state comes from core's compiled triiiceratops/testing entry.

It also includes inert stub services (createStubStyleService, createStubLocaleService, createStubUiService, createStubSurfaceService) for an activation with no viewer behind it. A PluginHost requires every service; pass these when the test is about something else. They live here rather than in the SDK's base entry so shipped plugin bundles never include them.

pnpm add -D @triiiceratops/plugin-sdk vitest

The flush timing rule

Notifications are batched and delivered on the reactive flush, never synchronously inside a command. After a command (or a locale/renderer change), await flush() before asserting a subscriber reacted:

import { describe, it, expect } from 'vitest';
import { createTestViewerContext, flush } from '@triiiceratops/plugin-sdk/testing';

describe('viewer state notifications', () => {
    it('delivers on the flush, not synchronously', async () => {
        const { context } = createTestViewerContext();
        const open = context.selectors.select((s) => s.toolbarOpen);

        let seen = open.get();
        open.subscribe((v) => {
            seen = v;
        });

        context.viewerState.toggleToolbar();
        // Batched: no synchronous delivery yet.
        expect(seen).toBe(false);

        await flush();
        expect(seen).toBe(true);
    });
});

Mounting a plugin against real state

Activate your plugin against the context's live state and assert its DOM and cleanup. The recording style service records installs so you can assert they were released on deactivation:

import { describe, it, expect } from 'vitest';
import { activatePlugin } from '@triiiceratops/plugin-sdk';
import { createTestViewerContext, flush } from '@triiiceratops/plugin-sdk/testing';
import {
    CORE_VERSION,
    pluginApiVersion,
    capabilities,
} from 'triiiceratops/testing';
import { createExamplePlugin } from './my-plugin';

describe('example plugin', () => {
    it('reacts to a command and cleans up', async () => {
        const tc = createTestViewerContext();
        const container = document.createElement('div');
        const activation = activatePlugin(createExamplePlugin(), {
            container,
            viewerState: tc.viewerState,
            coreVersion: CORE_VERSION,
            pluginApiVersion,
            capabilities,
            styles: tc.styles,
            locale: tc.locale,
            ui: tc.ui,
            // The REAL surface over the real state — see the trap below.
            surface: tc.surface,
            // Every guarded phase failure lands here. Rethrowing turns a broken
            // activation into a failing test instead of a silent one.
            reportError: (report) => {
                throw report.error;
            },
        });

        const label = container.querySelector('span');
        expect(label?.textContent).toBe('closed');

        tc.viewerState.toggleToolbar();
        await flush();
        expect(label?.textContent).toBe('open');

        activation.deactivate();
        expect(tc.styles.installed.every((s) => s.released)).toBe(true);
    });
});

Your plugin's surface id

createTestViewerContext binds the surface to one chrome id, uiId, defaulting to 'test-plugin', and seeds the viewer's plugin UI state for it. The viewer identifies your plugin only by that id, so it is the only prefix registerOverlayLayer accepts. Two common mistakes both leave a layer that never mounts:

  • Your plugin hardcodes its own name in the layer id. Derive it from context.surface.id instead. In a test that is uiId, in production it is your declared uiId or your sanitized package name, and in neither case is it guaranteed to be the string you typed.

  • You pass a stub surface instead of tc.surface. createStubSurfaceService answers with whatever id you gave it, and the viewer rejects ids it did not register, so every layer is refused. Pass surface: tc.surface; runPluginConformance already does.

A refusal is reported on the host's viewererror channel (code: 'overlay-layer-refused'), not thrown, and mount is never called. To make a refusal fail the test, set an error reporter: tc.viewerState.setErrorReporter((error) => { throw new Error(error.message); }). Pass uiId: '<your plugin's uiId>' when your plugin declares one, so the test id and the production id are the same string.

The conformance suite

runPluginConformance runs the whole SDK lifecycle battery against your plugin factory. It registers its own describe/it blocks, so call it at the top level of a test file:

import { runPluginConformance } from '@triiiceratops/plugin-sdk/testing';
import { createExamplePlugin } from './my-plugin';

runPluginConformance(() => createExamplePlugin());

Renderer-dependent behavior

The kit ships a headless renderer stand-in. Because the renderer is first-party, the stand-in reports what the real renderer would. Mount it with attachRenderer(...) to exercise the readiness path, the viewport queries, and the frame selector cadence with no DOM:

import {
    createTestViewerContext,
    whenRendererReady,
} from '@triiiceratops/plugin-sdk/testing';

async function readinessExample() {
    const tc = createTestViewerContext();
    const ready = whenRendererReady(tc.viewerState);
    const renderer = tc.attachRenderer({ scale: 2 }); // sized surface
    await ready;

    // Move the viewport and fire one animation event, synchronously.
    renderer.setView({ scale: 4 });
    renderer.emitFrame();

    // Tap the image surface at a screen-space point — the gesture reserved for
    // annotation selection — without synthesizing pointer events.
    renderer.emitTap({ x: 120, y: 80 });

    // And read what a command sent to the renderer.
    tc.viewerState.zoomIn();
    return renderer.calls;
}

emitTap reaches every viewerState.subscribeSurfaceTap listener. The stand-in does not decide what was tapped: a real tap arrives already filtered by the renderer's single arbitration point (never a drag, a pinch, or a gesture suppressed by an input claim), and the subscriber determines which annotation a point selects from its own geometry.

Test real pixel behavior in a browser.

Verified against the packed packages

CI runs the vitest-kit fixture: the SDK test kit against the compiled triiiceratops/testing entry in a plain Vitest project, installed from the packed tarballs the way a consumer installs it.