npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pipedrive-app-extensions-mock-host

v0.4.2

Published

Development-only mock host for the Pipedrive App Extensions SDK: lets your app extension's code run on localhost — talking to the real @pipedrive/app-extensions-sdk — by playing the Pipedrive window and rendering real UI elements. Framework-agnostic.

Readme

pipedrive-app-extensions-mock-host

A development-only mock host for the Pipedrive App Extensions SDK (@pipedrive/app-extensions-sdk).

In production your app extension runs inside a Pipedrive iframe, and the SDK posts messages to the Pipedrive window (window.parent). On localhost there is no Pipedrive on the other end, so the SDK has nobody to talk to. This package plays that missing window: it listens for the SDK's messages and answers them, rendering real UI elements (snackbars, confirmations, modals, surface header bars, …) into your page. It is framework-agnostic — works with React, Vue, Next.js, or plain vanilla JS.

It does not replace the SDK. You keep using the real @pipedrive/app-extensions-sdk; this is the host it connects to.

The mock host running on localhost: a Custom Panel surface with a "Your app renders here" placeholder and header bar, a snackbar reading "MOCK Deal saved!" in the corner, and the Dev Tool's Active Log showing the app's show_snackbar command.

How it works

The real SDK and the mock host talk over the exact same wire protocol the SDK uses in production:

  1. Handshake. When your code calls new AppExtensionsSDK(...).initialize(), the SDK posts an initialize message to window.parent. Because you are not in an iframe in dev, window.parent === window, so the mock host — listening on that same window — receives it and replies, completing the handshake.
  2. Commands. Each sdk.execute(Command.X, args) opens a MessageChannel: one request, one reply. The host parses the command, renders the matching UI (or runs your headless override), and replies on the same port — which resolves the SDK's promise. Reply shapes match the SDK's own types exactly.
  3. Events. The host can push messages to your App Extension over time (e.g. VISIBILITY, USER_SETTINGS_CHANGE) via the controller's emit(). Your sdk.listen(Event.X, cb) receives them. The host also fires some events on its own from user interaction: closing a custom modal (its X button or CLOSE_MODAL) fires CLOSE_CUSTOM_MODAL, closing a floating window via its X fires VISIBILITY with context.invoker = 'user', and collapsing/expanding the panel fires VISIBILITY (is_visible false/true, invoker: 'user').
  4. Tracks. Fire-and-forget messages the SDK sends (e.g. FOCUSED) are received and swallowed — no reply, by design.

All host-rendered UI lives inside a single open Shadow DOM root, so the host's styles never leak into your app and your app's styles never reach the host UI. The published package contains zero third-party code and has no runtime dependencies.

Data flow between the App Extension, the Real SDK, and the Mock Host over a MessageChannel — your code drives the SDK, the SDK and host exchange messages on the same window, and the host renders its UI and pushes events back:

flowchart LR
    code["App Extension code"]
    sdk["Real SDK<br/>@pipedrive/app-extensions-sdk"]
    host["Mock Host<br/>listens on the same window"]
    ui["Shadow DOM UI<br/>surfaces · snackbar · modals · Dev Tool"]

    code <-->|"execute() · listen() · track()"| sdk
    sdk -->|"handshake, Commands, Tracks<br/>(postMessage / MessageChannel)"| host
    host -.->|"Command replies & Events"| sdk
    host --> ui

Requirements

  • The real @pipedrive/app-extensions-sdk installed (it is a peerDependency, >=0.16.0).
  • Node >=20 for development of this package.
  • Your app must not run inside an iframe in dev, so that window.parent === window and the mock host can listen on the same window.

Install

npm install --save-dev pipedrive-app-extensions-mock-host

Quick start

import { startPipedriveMockHost } from 'pipedrive-app-extensions-mock-host';
import AppExtensionsSDK, { Command } from '@pipedrive/app-extensions-sdk';

// Detect dev however you like (Vite: import.meta.env.DEV, Node:
// process.env.NODE_ENV, or a hostname check for vanilla JS).
const isDev = location.hostname === 'localhost';

// Start the host only in development.
const host = isDev ? startPipedriveMockHost() : undefined;

// The real SDK, pointed at the mock host (no iframe → identifier must be given).
const sdk = await new AppExtensionsSDK(
  isDev ? { identifier: 'dev-local' } : undefined,
).initialize();

await sdk.execute(Command.SHOW_SNACKBAR, {
  message: 'Hello from the mock host!',
});

// Later, when tearing down dev tooling:
host?.teardown();

In a plain HTML page, load the SDK's UMD build and this package's IIFE build (window.PipedriveMockHost) side by side — no bundler required. See testing/index.html for a complete vanilla example.

Configuration

startPipedriveMockHost(config?) accepts a MockHostConfig. Every field is optional:

| Option | Type | Default | Purpose | | ---------------- | ------------------------------------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | enabled | boolean | true | Turn the host off without removing the call (e.g. { enabled: import.meta.env.DEV }). When false, returns an inert handle. | | theme | 'light' \| 'dark' | 'light' | Visual theme for the host's own mock UI. | | onConfirmation | (args) => boolean \| Promise<boolean> | — | Headless override for SHOW_CONFIRMATION; return whether the user confirmed. Omit to render an interactive dialog. | | getSignedToken | () => string \| Promise<string> | 'dev-signed-token' | Provides the token returned by GET_SIGNED_TOKEN. Return a real dev JWT to exercise your backend's verify path. | | onModal | (attrs) => ModalResult \| Promise<ModalResult> | — | Headless override for OPEN_MODAL; return the modal result instead of rendering a dialog. | | customModals | Record<string, string> \| ((attrs) => string \| undefined) | — | Maps a custom-modal action_id to the URL the modal iframe should load. | | appName | string | 'App Extension' | Name shown in the surface header bar the host injects onto each surface. | | appIcon | string | a generic glyph | Icon shown in the surface header bar — a URL (rendered as an <img>) or a short glyph/emoji. | | devTool | boolean \| { position?, startCollapsed? } | true | Show the host's interactive Dev Tool overlay (see Dev Tool). Pass false to omit it, or an object to set its corner / start it collapsed. |

Theme and header branding

const host = startPipedriveMockHost({
  theme: 'dark',
  appName: 'Acme CRM Helper',
  appIcon: '/logo.svg', // a URL → rendered as <img>; or a glyph like '🚀'
});

Headless overrides (skip the UI)

Pass onConfirmation and onModal to answer those commands without rendering a dialog — handy for automated runs and end-to-end tests:

startPipedriveMockHost({
  // Always confirm, instead of showing the confirmation dialog.
  onConfirmation: () => true,
  // Resolve OPEN_MODAL with a fixed result instead of opening a modal.
  onModal: (attrs) => ({ status: 'submitted', id: 123 }),
});

Signed token and custom modals

import { SignJWT } from 'jose';

startPipedriveMockHost({
  // Return a real dev JWT to exercise your backend's verify path.
  getSignedToken: async () =>
    new SignJWT({ sub: 'dev-user' })
      .setProtectedHeader({ alg: 'HS256' })
      .sign(new TextEncoder().encode('dev-secret')),
  // Map a custom-modal action_id → the URL its iframe loads.
  customModals: {
    'settings-modal': '/dev/settings.html',
  },
});

customModals can also be a function, e.g. to build the URL from the modal's arguments:

startPipedriveMockHost({
  customModals: (attrs) => `/dev/modals/${attrs.action_id}.html`,
});

Controller API

startPipedriveMockHost() returns a MockHost controller:

| Member | Signature | Purpose | | ------------ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | shadowRoot | ShadowRoot | The open shadow root the host renders its UI into. Query it in tests (within(shadowRoot)). | | emit | (event: string, data) => void | Push a host-driven event to the App Extension (e.g. USER_SETTINGS_CHANGE, VISIBILITY). | | getCalls | () => MockHostCall[] | The commands the App Extension has sent so far ({ command, args }) — useful in tests. | | devTool | { setPosition(p) => void } | Runtime controls for the Dev Tool overlay; setPosition moves it to a corner (no-op when the Dev Tool is disabled). See Dev Tool. | | teardown | () => void | Stop listening and remove all rendered UI. |

Pushing events to the App Extension

The App Extension listens with sdk.listen(...); the host pushes events with emit(...). Use it to simulate Pipedrive-driven changes like a theme switch or visibility change:

import AppExtensionsSDK, { Event } from '@pipedrive/app-extensions-sdk';

const host = startPipedriveMockHost();
const sdk = await new AppExtensionsSDK({
  identifier: 'dev-local',
}).initialize();

// App side: react to host-driven events.
sdk.listen(Event.USER_SETTINGS_CHANGE, ({ data }) => {
  console.log('theme is now', data.theme);
});

// Host side: simulate Pipedrive flipping the theme to dark.
host.emit(Event.USER_SETTINGS_CHANGE, { theme: 'dark' });

Inspecting sent commands in tests

getCalls() returns every command the App Extension has sent, so you can assert on them, then teardown() to clean up:

const host = startPipedriveMockHost();
const sdk = await new AppExtensionsSDK({
  identifier: 'dev-local',
}).initialize();

await sdk.execute(Command.SHOW_SNACKBAR, { message: 'Saved!' });

expect(host.getCalls()).toContainEqual({
  command: 'show_snackbar',
  args: { message: 'Saved!' },
});

host.teardown(); // stop listening and remove all rendered UI

Supported commands

The host implements the App Extension command set and renders the same UI Pipedrive would. Reply shapes match the SDK's own types.

| Command | What the host does | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | INITIALIZE | Completes the handshake; applies the optional initial size to the active surface. | | SHOW_SNACKBAR | Renders a transient snackbar (bottom-right), auto-dismissing after ~5s; supports an optional link button. | | SHOW_CONFIRMATION | Renders a confirmation dialog and resolves { confirmed }; or uses your onConfirmation override. | | OPEN_MODAL | Opens a custom modal (loads a URL via customModals) or an entity modal (a Pipedrive-style create-record form with prefilled values); or uses your onModal override. | | CLOSE_MODAL | Closes an open custom modal and fires CLOSE_CUSTOM_MODAL. (Entity modals are native forms the app cannot close.) | | REDIRECT_TO | Shows a redirect banner (auto-dismisses after ~4s) naming the target view/id. | | SET_NOTIFICATION | Shows a notification badge. Floating-window only. | | SET_FOCUS_MODE | Toggles focus mode, disabling the floating window's close button. Floating-window only. | | SHOW_FLOATING_WINDOW / HIDE_FLOATING_WINDOW | Shows / hides the floating-window surface. Floating-window only. | | RESIZE | Resizes the active surface, clamped to that surface type's bounds (out-of-range requests are rejected). | | GET_METADATA | Returns the hosting window's { windowWidth, windowHeight } (the dev browser viewport) — not the surface's own size — so apps can size a surface relative to it. | | GET_SIGNED_TOKEN | Returns { token } from getSignedToken (default 'dev-signed-token'). |

Surface-scoped commands run on the wrong surface log a dev-only diagnostic and still reply (so the SDK promise never hangs).

Example calls

// A snackbar with an action link.
await sdk.execute(Command.SHOW_SNACKBAR, {
  message: 'Deal saved!',
  link: { url: '/deals/42', label: 'View' },
});

// A confirmation dialog → resolves with the user's choice.
const { confirmed } = await sdk.execute(Command.SHOW_CONFIRMATION, {
  title: 'Delete this deal?',
  description: 'This cannot be undone.',
  okText: 'Delete',
  okColor: 'negative',
});

// Open an entity modal (Pipedrive create-record form) with prefilled values.
const result = await sdk.execute(Command.OPEN_MODAL, {
  type: 'activity',
  prefill: { subject: 'Follow up', dueDate: '2026-07-01' },
});

// Open a custom modal whose iframe loads the URL mapped in `customModals`.
await sdk.execute(Command.OPEN_MODAL, {
  type: 'custom_modal',
  action_id: 'settings-modal',
});

// Resize the active surface, then read the hosting window's size (e.g. to size
// a modal relative to it). GET_METADATA reports the window, not the surface.
await sdk.execute(Command.RESIZE, { height: 600 });
const { windowWidth, windowHeight } = await sdk.execute(Command.GET_METADATA);

// Get a signed token (whatever `getSignedToken` returns).
const { token } = await sdk.execute(Command.GET_SIGNED_TOKEN);

A mock-host confirmation dialog ("Delete this deal?" with Cancel and Delete buttons) centred over the dimmed playground, with the Dev Tool's Active Log showing the app's show_confirmation command.

Surfaces

A Surface is the element standing in for the place in Pipedrive where your App Extension renders. RESIZE sizes it. (GET_METADATA reports the hosting window, not the surface — see Supported commands.) You opt in by wrapping your app in an element with the host's class (or id):

| Surface | Wrapper class / id | Width | Height | | --------------- | ------------------------- | ---------------- | ---------------- | | Custom Panel | pd-mock-panel | fixed ~385px | 100–750px | | Custom Modal | pd-mock-modal | 320px – viewport | 120px – viewport | | Floating Window | pd-mock-floating-window | 200–800px | 70–700px |

<div class="pd-mock-panel">
  <div class="pd-mock-scroll-layer">
    <!-- your App Extension renders here -->
  </div>
</div>

The same wrapper in React (or any framework):

export function App() {
  return (
    <div className="pd-mock-panel">
      <div className="pd-mock-scroll-layer">
        {/* your App Extension renders here */}
      </div>
    </div>
  );
}

The inner .pd-mock-scroll-layer makes the surface behave like Pipedrive's production frame: a position: fixed footer pins to the surface and there is a single scrollbar (see Pinned footers and scrolling). You can omit it<div class="pd-mock-panel">…</div> on its own works too, and the surface simply scrolls itself.

  • You don't write CSS for surfaces — the class is enough; the host injects the styling and positioning.

  • Using the host name as an id (<div id="pd-mock-panel">) gives the same behaviour (resize bounds, metadata, floating-window commands) without the host's visual styling, so you can style the element yourself:

    <!-- behaviour without the host's look — you style it -->
    <div
      id="pd-mock-floating-window"
      style="background: #fff; border: 1px solid #ccc;"
    >
      <!-- your App Extension renders here -->
    </div>
  • The host injects a surface header bar as the first child of each class-identified surface, reproducing the title bar Pipedrive frames each surface with: a collapse chevron, app icon + name, refresh, and a "more (⋯)" button on the panel; a close (X) on modals and floating windows.

Pinned footers and scrolling (the scroll layer)

In production each surface is a real <iframe> inside an overflow: hidden wrapper, so a position: fixed; bottom: 0 footer pins to the surface bottom while the app scrolls. The host renders surfaces as plain <div>s (no iframe); the .pd-mock-scroll-layer from the standard example above stands in for that iframe. Put your pinned footer inside it:

<div class="pd-mock-panel">
  <div class="pd-mock-scroll-layer">
    <!-- your App Extension renders here -->
    <footer style="position: fixed; bottom: 0">…</footer>
  </div>
</div>

With the scroll layer present, the surface becomes a non-scrolling frame and the scroll layer is the single scroll container, so a position: fixed footer pins to the surface instead of the browser window — the same CSS that works in production. It is opt-in: without .pd-mock-scroll-layer, the surface scrolls itself as before.

  • The scroll layer is your element — add it in your own markup/JSX. The host never moves your content (re-parenting framework-rendered nodes would break the framework); it only injects its header as the wrapper's first child.
  • Works on all three surfaces (panel, modal, floating window).
  • If your layout does not already reserve the footer's height, add padding-bottom: <footer height> to .pd-mock-scroll-layer so the last content clears the footer.

See ADR-0010.

Which element becomes the active surface

The host picks the first element (in DOM order) matching any surface class or id, and treats that as the surface RESIZE sizes:

  • No wrapper present? The host falls back to document.body, so RESIZE still works (it acts on the body). GET_METADATA is unaffected — it always reports the hosting window. (You won't get a surface header bar, since there is no wrapper to inject it into.)
  • More than one wrapper? The first in DOM order wins; the rest are ignored. Render only one surface wrapper at a time.

The mock host's Custom Panel header bar with collapse, app name, refresh, and more buttons.

A mock-host Custom Modal with a header bar and close button.

A mock-host floating window in focus mode (top-right) with a disabled close button and a focus-mode badge.

Dev Tool

The host renders its own interactive Dev Tool overlay — no consumer markup needed, it appears as soon as the host starts. It docks to a corner (bottom-left by default) and can be collapsed to a compact launcher by clicking anywhere on its header bar (not just the +/ button). It has two columns: Controls on the left, the Active Log on the right.

The mock host's Dev Tool docked in the bottom-right corner of the Custom Panel page. The app surface on the left is an empty "Your app renders here" placeholder; the Dev Tool shows a green "Mock host" header, a Controls column (Theme, Visibility, Page, Resize) and an Active Log listing the app's initialize command.

The Controls drive the host the way Pipedrive would — they emit Events to your App Extension and resize the surface (the Dev Tool never sends Commands; only the app can):

  • Theme — emit USER_SETTINGS_CHANGE (light / dark).
  • Visibility — emit VISIBILITY (is_visible + invoker).
  • Page — drive PAGE_VISIBILITY_STATE (visible / hidden). The SDK reads page visibility from the document, not the host event channel, so this control dispatches a real visibilitychange on the page — exactly what a browser tab switch does — and your listen(PAGE_VISIBILITY_STATE) callback fires.
  • Resize — resize the active surface, clamped to that surface's bounds.
  • Focus mode — disable the floating window's close button (floating-window only).
  • Floating window — show/hide the floating window (floating-window only).

The controls are surface-type aware: the Focus mode and Floating window controls appear only while a Floating Window is the active surface, and the Dev Tool tracks the DOM so it stays in sync as your framework mounts and unmounts the surface wrapper.

The Dev Tool on the Floating Window page. Because a floating window is the active surface, the Controls column now also shows the Focus mode and Floating window toggles that stay hidden on the panel and modal pages.

The Active Log records activity, newest-first, each entry tagged with its direction: the Commands the App Extension sent (e.g. app → host command: show_snackbar …), the Tracks it fired, the Events the host pushed back (e.g. host → app event: visibility … — including the user-invoked VISIBILITY fired when you collapse the panel), and the Dev Tool's own actions (e.g. dev tool action: resize …). The panel is capped in height and scrolls.

The Dev Tool in its default bottom-left corner alongside a centred Custom Modal. The modal surface is an empty "Your app renders here" placeholder over a dimmed backdrop, and the Focus mode / Floating window controls are absent because the active surface is a modal.

Choose the corner, or turn it off entirely:

startPipedriveMockHost({ devTool: false }); // off
startPipedriveMockHost({ devTool: { position: 'bottom-right' } }); // pick a corner
startPipedriveMockHost({ devTool: { startCollapsed: true } }); // start collapsed

To move it at runtime — e.g. a different corner per view in a single-page app — call setPosition on the controller:

const host = startPipedriveMockHost();
host.devTool.setPosition('top-right'); // bottom-left | bottom-right | top-left | top-right

Examples

The testing/ folder contains ready-to-run examples covering every command, event, and surface:

  • Vanilla HTML playground — load the built bundles with no bundler. Each surface is a "Your app renders here" placeholder, so the Dev Tool is what you drive:

    • testing/index.html — the Custom Panel surface. It exposes the initialized SDK as window.sdk, so you can fire app-side Commands (the ones the Dev Tool can't send) straight from the devtools console, e.g. sdk.execute(AppExtensionsSDK.Command.SHOW_SNACKBAR, …).
    • testing/modal.html — the Custom Modal surface (resize it from the Dev Tool).
    • testing/floating-window.html — the Floating Window surface; resize it and toggle focus mode / visibility from the Dev Tool, or fire the show/hide Commands from the in-page buttons.
    • testing/custom-modal.html — the page loaded inside a custom modal's iframe.

    Run them from the repo root:

    npm run build
    npx http-server . -p 8080   # or: python3 -m http.server 8080
    # open http://localhost:8080/testing/  (add ?theme=dark for the dark UI)
  • Next.js playgroundtesting/next-app/ is a small Next.js app (pages router) wiring the host into a real framework, with a separate page per surface (/, /modal, /floating-window, /panel) and a shared MockHostPlayground component. Run it with npm install && npm run dev inside that folder.

Lifecycle and edge cases

A few behaviours worth knowing when wiring the host into a dev setup:

  • One host at a time. Calling startPipedriveMockHost() again before teardown() does not start a second host — it logs a warning and returns the existing instance. (This keeps a single listener on window; two would double-process every command.) Under hot-module-reload / fast refresh, call teardown() first if you need a fresh instance:

    let host = startPipedriveMockHost();
    
    // e.g. on an HMR dispose hook:
    import.meta.hot?.dispose(() => host.teardown());
  • Server-side rendering is safe. With no window (e.g. a Next.js server render), startPipedriveMockHost() returns an inert handle and does nothing — so a top-level call won't crash SSR. The host only comes alive in the browser.

Keeping it out of production

This is a development-only tool. The reliable way to keep it out of your production bundle is a dynamic import() behind a build-time dev gate, so the bundler code-splits the package into a chunk production never loads:

import type { MockHost } from 'pipedrive-app-extensions-mock-host';

let host: MockHost | undefined;
if (process.env.NODE_ENV !== 'production') {
  const { startPipedriveMockHost } =
    await import('pipedrive-app-extensions-mock-host');
  host = startPipedriveMockHost();
}

This is what the Next.js playground does, and its production build contains zero bytes of the package (verified by grepping the built chunks). Keep any types as import type — they erase at compile time and pull no runtime code in.

Why not a plain static import?

A static import { startPipedriveMockHost } from '…' can be tree-shaken — but only when the bundler can prove the call is dead code, which is easy to break. It works when the gate is an inlined flag checked right at the call site and sideEffects: false then lets the bundler drop the now-unused import:

import { startPipedriveMockHost } from 'pipedrive-app-extensions-mock-host';
// Tree-shaken out in production by Vite/Rollup AND Next.js/Turbopack (measured):
if (process.env.NODE_ENV !== 'production') startPipedriveMockHost();

Two common variations silently ship the package instead:

  • Gate behind a helper functionif (shouldStart()) startPipedriveMockHost(); where shouldStart() lives in another module. The bundler can't prove the call dead across the function boundary, so it keeps the import. (This bit a real consumer: ~65 KB gzip, duplicated across every route chunk.)
  • Unconditional callstartPipedriveMockHost({ enabled: … }) always ships; only its runtime behaviour is gated, not its presence in the bundle.

The dynamic import() above is robust against both: if the gate is ever not eliminated, the package lands in a separate on-demand chunk that initial page loads never fetch — it is never inlined into your route bundles. When in doubt, use it.

Runtime safety nets

Even if it does end up bundled and called in production, two guards keep it inert (they limit the damage; they do not remove it from the bundle):

  1. Install it as a devDependency (npm install --save-dev …).
  2. Started with NODE_ENV=production, it console.warns and stays inert (injects nothing, registers no listener). { enabled: false } returns the inert handle with no warning, for an explicit off-switch.

Development

npm install          # install dependencies
npm run dev          # run tests in watch mode (vitest)
npm test             # run all tests once (unit + browser)
npm run test:unit    # fast logic/DOM tests only (jsdom)
npm run test:browser # UI tests in real Chromium (Vitest Browser Mode)
npm run build        # bundle ESM + CJS + IIFE with tsup, emit declarations
npm run ci           # build + typecheck + check formatting + test (what CI runs)

Testing strategy

Two Vitest projects (see vitest.config.ts):

  • unit — fast tests in jsdom for host logic and DOM structure (src/**/*.test.ts). jsdom does not transfer MessagePorts through postMessage, so these tests simulate the wire protocol directly.
  • browser — UI and real-SDK integration tests in Chromium via Vitest Browser Mode + Playwright (src/**/*.browser.test.ts), where port transfer works. UI is queried via the open Shadow DOM root (within(host.shadowRoot)).

Both use Testing Library and @testing-library/jest-dom.

Releasing

Changesets:

npx changeset         # describe your change
npm run local-release # version + publish to npm

License

MIT © Antti Lahtinen