Skip to content

Testing ​

A module's Shell runs inside Building OS, which supplies what the module reads from the platform. In a test nothing supplies it, so the test does:

In Building OSIn your test
The header publishes the selected building, and useSelectedBuildingId() reads itWrite the same selection with BUILDING_SELECTION_STORAGE_KEY
authorizationHeader() carries the signed-in user's tokenThere is no token ({}); mock fetch
A QueryClientProvider around every moduleRender inside your own, with retries off
The shell's languageSwitch your i18next instance with changeLanguage()

This page builds on step 6 of Your first module and uses its Shell. Its code was checked on a fresh --external scaffold of SDK 1.15.0.

Set up ​

The scaffold's Vitest runs in Node, with no DOM and no testing library. Add them (-w is required in the scaffold, whose pnpm-workspace.yaml otherwise refuses to add to the root):

bash
pnpm add -D -w @testing-library/react @testing-library/dom @testing-library/jest-dom jsdom

Start every test file that renders a component with // @vitest-environment jsdom on its first line, and import @testing-library/jest-dom/vitest for matchers such as toBeInTheDocument().

Two helpers ​

Put what every test of the Shell needs in one file:

tsx
// src/test/render.tsx
import type { ReactElement } from 'react';
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BUILDING_SELECTION_STORAGE_KEY } from '@tv/extension-sdk/context';

/** Publish a building selection the way the Building OS header does. */
export function selectBuilding(id: string, name = id) {
  localStorage.setItem(
    BUILDING_SELECTION_STORAGE_KEY,
    JSON.stringify({ state: { selectedBuildingId: id, buildings: [{ id, name }] } }),
  );
}

/** Render inside a fresh query client, with retries off so a failure shows at once. */
export function renderWithQueryClient(ui: ReactElement) {
  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}

BUILDING_SELECTION_STORAGE_KEY is exported for exactly this: seeding a selection outside the shell. Read the selection in your module only through useSelectedBuildingId() or useBuildingSelection(), never through the key, which may change with the shell.

Stub the API, and follow a change of building ​

vi.stubGlobal('fetch', …) replaces fetch for one test; answer each URL the module calls. Dispatching BUILDING_SELECTION_CHANGE_EVENT is how the header tells modules the selection changed:

tsx
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest';
import { act, cleanup, screen } from '@testing-library/react';
import { BUILDING_SELECTION_CHANGE_EVENT } from '@tv/extension-sdk/context';
import i18n from './i18n';
import Shell from './Shell';
import { renderWithQueryClient, selectBuilding } from './test/render';

/** Answer /buildings/<id>/spaces with the given totals, anything else with 404. */
function stubSpaces(totals: Record<string, number>) {
  const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
    const match = /\/buildings\/([^/]+)\/spaces$/.exec(String(input));
    const total = match ? totals[decodeURIComponent(match[1])] : undefined;
    return total === undefined
      ? new Response('not found', { status: 404 })
      : Response.json({ data: [], total, limit: 50, offset: 0 });
  });
  vi.stubGlobal('fetch', fetchMock);
  return fetchMock;
}

beforeAll(async () => {
  await i18n.changeLanguage('en');
});

beforeEach(() => {
  localStorage.clear();
});

afterEach(() => {
  cleanup();
  vi.unstubAllGlobals();
});

test('says so when the API call fails', async () => {
  selectBuilding('b1');
  stubSpaces({});
  renderWithQueryClient(<Shell />);
  expect(await screen.findByText('Could not load the spaces.')).toBeInTheDocument();
});

test('follows the building the user picks in the header', async () => {
  selectBuilding('b1');
  stubSpaces({ b1: 3, b2: 1 });
  renderWithQueryClient(<Shell />);
  expect(await screen.findByText('3 spaces')).toBeInTheDocument();

  act(() => {
    selectBuilding('b2');
    window.dispatchEvent(new Event(BUILDING_SELECTION_CHANGE_EVENT));
  });
  expect(await screen.findByText('1 space')).toBeInTheDocument();
});

i18n.changeLanguage('en') matters: the platform's default language is Russian, so without it the Shell renders your Russian catalogue.

createMockPlatformContext ​

createMockPlatformContext() from @tv/extension-sdk/testing builds a whole PlatformContext with realistic defaults: a mall operations manager viewing their building in English, light theme. The scaffold's src/main.tsx uses it for the standalone preview.

You rarely need it in a module's tests. Your module's own code must not read the context, because usePlatformContext() and the hooks built on it throw inside Building OS (see The PlatformContext). Rendering the Shell inside a <PlatformProvider> in a test therefore proves nothing about the shell: useSelectedBuildingId() does not read it.

Overrides are shallow

createMockPlatformContext(overrides) takes a Partial<PlatformContext> — shallow, not deep. Whatever top-level key you override, you supply whole. A building override needs every field of PlatformBuilding (id, slug, name, type, timezone); a partial one is a type error.

To tweak a single field, spread the default instead of hand-writing it:

ts
import { createMockPlatformContext } from '@tv/extension-sdk/testing';

const base = createMockPlatformContext();
export const ctx = createMockPlatformContext({
  building: { ...base.building!, name: 'Demo Mall' },
  user: { ...base.user, roles: ['manager'] },
});

The self-diagnosis checklist ​

Before writing to us, run through this — most issues resolve here:

  1. Which SDK version? cat node_modules/@tv/extension-sdk/package.json | jq .version
  2. Manifest valid? npx @tv/extension-sdk validate module-manifest.json
  3. Federation expose right? npx @tv/extension-sdk check-exposes module-manifest.json
  4. Subscriptions fit the producers? npx @tv/extension-sdk check-pact module-manifest.json
  5. Using an @experimental symbol? Grep your imports — those can change between releases.
  6. Heartbeat reporting? Check your module's dot in the Building OS sidebar.
  7. Do your tests render the Shell as Building OS does? Selection seeded, fetch mocked, a query client provided, and no <PlatformProvider> your code depends on.

If all seven are clean and it still breaks, write to the contact named in your welcome message, by email or Telegram, with the checklist output; developers@tango.vision reaches the same people.

When it works standalone and breaks in the shell ​

That is its own failure class, and none of the seven checks above can see it — your tests, your build and your image are all green by construction, because a standalone module has no host to conflict with. Three questions, in order:

  1. Does the error mention useSyncExternalStore or a null dispatcher? Your remote is bundling a second React. You are missing the use-sync-external-store/shim alias.
  2. Is the shell showing raw i18n keys (structure.title) while your module renders fine? You have shared react-i18next without i18next and replaced the host's default instance.
  3. Does usePlatformContext() throw? It does not cross the federation boundary — see The PlatformContext.

All three are covered in Federation and the shell. Inside Tango Vision, the shell-smoke CI check catches the first two before they reach a stand. It lives in a private repository, so your CI cannot run it: load your module in your sandbox shell after every change to vite.config.ts, your i18n setup or a shared dependency.

Built on the Tango Vision platform. Questions? developers@tango.vision