i99dash docs
Guides

Testing

How to test mini-app code with `MiniAppClient.withBridge()` — the one seam you need.

MiniAppClient.withBridge(...) is the one seam you need. Hand it any object that satisfies the Bridge (or CarStatusBridge) interface and the rest of the SDK runs unchanged. No platform channels, no WebView, no fixtures.

TL;DR — Vitest setup

// fuel-stations.test.ts
import { describe, expect, it } from 'vitest';
import { MiniAppClient, type Bridge } from '@i99dash/sdk';

import { renderStations } from './fuel-stations';

describe('renderStations', () => {
  it('shows nearest first', async () => {
    const bridge: Bridge = {
      getContext: async () => ({
        userId: 'u1',
        activeCarId: 'WDB1234567',
        locale: 'en',
        isDark: false,
        appVersion: '1.0.0',
        appId: 'fuel_prices',
      }),
      callApi: async (req) => {
        if (req.path === '/api/v1/fuel-stations') {
          return {
            success: true,
            data: { stations: [{ name: 'Aramco A', km: 0.4 }] },
          };
        }
        return { success: false, error: { code: 'disallowed_path', message: req.path } };
      },
    };
    const client = MiniAppClient.withBridge(bridge);
    const html = await renderStations(client);
    expect(html).toContain('Aramco A');
  });
});

No jsdom, no happy-domBridge is a plain TypeScript interface; the test runs in pure Node.

Asserting bridge calls

For "did the page actually call this endpoint?" tests, capture calls in a small spy:

import { vi } from 'vitest';

const callApi = vi.fn(async (req) => ({ success: true, data: null }));
const bridge: Bridge = {
  getContext: async () => ctx,
  callApi,
};

await render(MiniAppClient.withBridge(bridge));

expect(callApi).toHaveBeenCalledWith({
  path: '/api/v1/fuel-stations',
  method: 'GET',
  query: { lat: 24.7, lng: 46.6 },
});

Vitest's vi.fn (or Jest's jest.fn) gives you mock.calls for ad-hoc matchers; no need for a mock library.

Covering error paths

Each SDKError subclass is constructible — throw it from your fake bridge to drive the error branch:

import { BridgeTransportError, BridgeTimeoutError } from '@i99dash/sdk';

const bridge: Bridge = {
  getContext: async () => {
    throw new BridgeTransportError('host crashed', new Error('underlying'));
  },
  callApi: async () => ({ success: true, data: null }),
};

await expect(client.getContext()).rejects.toBeInstanceOf(BridgeTransportError);

For protocol failures ({success: false, error: {...}}) — those are first-class data, not exceptions — return the failure envelope from callApi and assert on the value, not the throw.

Testing client.car.*

The car-status surface needs a CarStatusBridge. Capture the notify callback so the test can synchronously inject status events:

import { describe, expect, it, vi } from 'vitest';
import { MiniAppClient, type CarStatusBridge } from '@i99dash/sdk';

let pushStatus: ((raw: unknown) => void) | undefined;

const bridge: CarStatusBridge = {
  // Base Bridge:
  getContext: async () => ctx,
  callApi: async () => ({ success: true, data: null }),
  // Streaming surface:
  async getCarStatus() {
    return {
      vin: 'V', at: '2026-04-27T12:00:00Z', staleness: 'fresh',
      doorsLocked: true,
    };
  },
  async subscribeCarStatus(notify) {
    pushStatus = notify;
    return { id: 'sub-1' };
  },
  async unsubscribeCarStatus() { pushStatus = undefined; },
  async subscribeCarConnectionState() { return { id: 'conn-1' }; },
  async unsubscribeCarConnectionState() {},
};

it('updates the lock indicator on lock change', async () => {
  const client = MiniAppClient.withBridge(bridge);
  const cb = vi.fn();
  client.car.onStatusChange(cb);
  // The lazy-subscribe is async — drain microtasks once.
  await Promise.resolve();

  pushStatus?.({
    vin: 'V', at: '2026-04-27T12:01:00Z', staleness: 'fresh',
    doorsLocked: false,
  });

  expect(cb).toHaveBeenCalledOnce();
  expect(cb.mock.calls[0][0].doorsLocked).toBe(false);
});

Testing privileged ops (@i99dash/admin-sdk)

Use FakeAdminBridge — same idea, but for the admin surface:

import { AdminClient, FakeAdminBridge, snapshotFromList } from '@i99dash/admin-sdk';

const bridge = new FakeAdminBridge(async (req) => {
  // Assert what your code sent the host:
  expect(req.templateId).toBe('diag.tail_logs');
  expect(req.idempotencyKey).toBeTruthy();
  return { success: true, data: { lines: ['boot ok'] } };
});

const admin = AdminClient.withBridge({
  bridge,
  context: { appId: 'diagnostics-pro', vin: 'WDB1234567' },
  catalog: snapshotFromList([/* CommandTemplate rows */]),
});

await admin.tailLogs({ lines: 50 });

For idempotency-key-replay tests, write the handler to track call counts and assert the second call with the same key short-circuits.

What to test

The SDK is well-tested on its own — what you should test in your mini-app is your code's reaction to bridge results:

Test caseWhat it catches
Happy path: real data → renderForgot to map a field, off-by-one in pagination
success: false envelopeForgot the failure branch — common production crash
BridgeTimeoutError thrownUI doesn't show a stuck spinner forever
NotInsideHostError (SSR)Server-rendered page doesn't crash on first paint
Empty activeCarId (no car bound)UI doesn't render with empty VIN; gates correctly
Stale car status (staleness: 'very_stale')UI shows "no signal" instead of pretending it's live
Subscription cleanup on unmountMemory leak under React useEffect strict-mode double-mount

What NOT to test

  • The SDK's schema validation — that's covered by the SDK's own __tests__/. You don't need to assert that getContext() returns a MiniAppContext-typed value; the type system already promises that.
  • The host's behaviour — your mini-app ships a fake bridge in CI; the real host's contract is integration-tested in the host repo, not yours.
  • Wire-format parsing — the SDK does the JSON / zod work.

On this page