Skip to content

Testing

The SDK ships an emulator under grist-widget-sdk/emulator that recreates the plugin API in-process. Used through renderWithGrist(...), your hooks take their real code path inside vitest/Jest.

Quick start

ts
import { describe, expect, it } from "vitest"
import { renderWithGrist, presets } from "grist-widget-sdk/emulator/testing"
import App from "./App"

describe("App", () => {
  it("renders the selected row title", async () => {
    const { emulator, findByText } = renderWithGrist(<App />, {
      emulator: { document: presets.todoList() },
    })

    emulator.cursor.select("Tasks", 1)

    expect(await findByText("Buy milk")).toBeInTheDocument()
  })
})

renderWithGrist:

  • Reuses @testing-library/react (render(ui, options) semantics).
  • Mounts the emulator behind window.grist exactly once.
  • Cleans up the singletons on unmount.

Configuration

ts
renderWithGrist(<App />, {
  // anything from RenderOptions (container, wrapper, etc.) is accepted
  emulator: {
    document: presets.contacts(),  // or your own GristReplicaDocument
    transport: { kind: "inline" }, // default
  },
})

You can also bring your own emulator instance:

ts
const emulator = createGristEmulator({ document: presets.blank() })
renderWithGrist(<App />, { existing: emulator })

Document fixtures

The emulator only accepts a complete GristReplicaDocument. Three preset shortcuts are available, or build your own:

ts
import { presets } from "grist-widget-sdk/emulator/testing"

presets.blank()      // one empty Table1
presets.todoList()   // Tasks table with sample rows
presets.contacts()   // Contacts table with multiple types

Custom fixtures live in your test repo:

ts
import type { GristReplicaDocument } from "grist-widget-sdk"

export const fixture: GristReplicaDocument = {
  generatedAt: "2026-01-01T00:00:00Z",
  tables: {
    Tasks: {
      columns: { Title: { type: "Text" }, Done: { type: "Bool" } },
      rows: [
        { id: 1, Title: "Buy milk", Done: false },
        { id: 2, Title: "Walk dog", Done: true },
      ],
    },
  },
}

Validate fixtures with parseDocument (Zod-backed) if they come from disk:

ts
import { parseDocument } from "grist-widget-sdk/emulator"
const doc = parseDocument(JSON.parse(json))

Mutating data in tests

The emulator gives you imperative mutators:

ts
emulator.mutate.addRow("Tasks", { Title: "Pay bills" })
emulator.mutate.updateRow("Tasks", 1, { Done: true })
emulator.mutate.removeRow("Tasks", 2)

emulator.cursor.select("Tasks", 1)            // moves the cursor
emulator.cursor.selectNewRow()                // sets mode: "new-row"
emulator.cursor.clear()                       // mode: "empty"

emulator.options.set({ theme: "dark" })       // widget options
emulator.theme.set("dark")                    // host theme

After each call, React state updates automatically through the normal subscription path.

Custom matchers

ts
import { actionsOf, eventsOf, waitForEvent } from "grist-widget-sdk/emulator/testing"

// All user actions Grist saw during the test
expect(actionsOf(emulator)).toContainEqual([
  "UpdateRecord",
  "Tasks",
  1,
  { Done: true },
])

// Every event Grist emitted to the widget
expect(eventsOf(emulator)).toEqual([
  { kind: "ready" },
  { kind: "record", record: { id: 1, ... } },
  ...
])

// Wait for the *next* bus event of a kind (ignores history — start the waiter first)
const optionsEvent = waitForEvent(emulator, "options")
await userEvent.click(screen.getByText("Save"))
await optionsEvent

useGristSchema snapshots

The SDK test suite snapshots useGristSchema() output for presets.blank(), presets.todoList(), and presets.contacts() across schema-only, schema+samples, and schema+data. Intentional replica changes require updating snapshots: pnpm --filter grist-widget-sdk test -- -u use-grist-schema.snapshot.

Component-level testing patterns

Hooks in isolation

When the unit under test is a hook, render a probe component:

tsx
function Probe({ onResult }: { onResult: (w: ReturnType<typeof useGrist>) => void }) {
  const w = useGrist()
  React.useEffect(() => onResult(w), [w])
  return null
}

renderWithGrist(<Probe onResult={spy} />, { emulator: {...} })

Live mutations

ts
emulator.mutate.updateRow("Tasks", 1, { Title: "Renamed" })
expect(await findByText("Renamed")).toBeInTheDocument()

Action assertions

ts
fireEvent.click(getByRole("button", { name: /save/i }))
await waitFor(() => {
  expect(actionsOf(emulator)).toContainEqual(["UpdateRecord", "Tasks", 1, expect.any(Object)])
})

Where the emulator runs

Use caseTransportMount
Unit tests (vitest jsdom)inlinerenderWithGrist
Storybook / IDE previewinlinecreateGristEmulator({ transport: { kind: "inline" } })
Production-fidelity demoiframecreateGristEmulator({ transport: { kind: "iframe", iframe } })

See /api/emulator for the lower-level API.

Released under the ISC License.