Sandbox-first
The working rule for new building blocks:
Build the sandbox harness first. Real app UI comes after.
A building block is not done when its package typechecks — it is done when it can be exercised end-to-end on a harness page, in isolation, by a fresh user.
The order
Loading diagram...
- The package — the block lives in
packages/<block>with a browser stub and its own typecheck. - The sandbox page — a route under
/sandboxthat mounts the block through its real seam (server function → package → infrastructure) with minimal chrome and stabledata-testids. The whole/sandboxsubtree requires site admin on production builds. - The block spec — signs up a fresh user, drives the page, asserts the round trip. No seed data, no app navigation. (Exception: site-admin blocks sign in as the seeded site admin.)
- Only then is real app UI built on top.
Why not mocks?
- The transport is not a public contract. The app speaks server functions — an internal wire format. Mocking it couples tests to framework internals.
- A mocked backend proves the wrong thing — that the UI works against our guess of the API. The sandbox proves the real seam: server function → package → database / storage / SMTP.
- The real backend is cheap:
docker compose upplus Bun. - Pure UI isolation without any backend is a different, already-solved need: presentational components that take data as props.
Two test tiers with different jobs
| Tier | Where | The question it answers |
|---|---|---|
| Journey tests | e2e/*.spec.ts | Does the app do the right thing? Full UI, seeded data, flows |
| Block tests | e2e/sandbox/*.spec.ts | Does the block work? Fresh user, one seam, seconds to run |
When something breaks, the failing tier tells you where to look — the block, or the app around it.
A real block spec
Project CRUD through the real server functions, self-sufficient from the first line (live code):
import { expect, test } from "@playwright/test";
import { gotoStable, signOutIfSignedIn, signUp, uniqueEmail } from "../helpers";
// Grundsten: projekt-CRUD genom de RIKTIGA server-functionerna
// (createProject/deleteProject) — i en personlig sandbox-workspace, så en
// helt färsk användare räcker. Ingen dashboard, inga paneler.
test("sandbox/projects: skapa och radera ett projekt genom den riktiga seamen", async ({
page,
}) => {
await signOutIfSignedIn(page);
await signUp(page, {
name: "Sandbox Projektör",
email: uniqueEmail(),
password: "password123",
});
await gotoStable(page, "/sandbox/projects");
const root = page.getByTestId("sandbox-projects");
await expect(root).toBeVisible({ timeout: 10_000 });
const name = `Sandbox E2E ${Date.now()}`;
await root.getByLabel("Projektnamn").fill(name);
await root.getByRole("button", { name: "Skapa projekt" }).click();
const row = page.getByTestId("sandbox-project-list").locator("li", { hasText: name });
await expect(row).toBeVisible({ timeout: 10_000 });
await row.getByRole("button", { name: "Radera" }).click();
await expect(row).not.toBeVisible({ timeout: 10_000 });
});Rules for harness plumbing
example/src/lib/sandbox-server.ts may only contain rig setup — a workspace to
create projects in, a storage prefix to upload to. Two guardrails:
- A sandbox page must call the real server functions where they exist.
- The moment a harness function starts looking like a feature, that is the signal the
app needs it — promote it to a proper
*-server.tsmodule and import it back into the sandbox. (That is howcreateWorkspacebecame a real server function.)
Definition of done for a new block
- Package in
packages/<block>with a browser stub and its own typecheck - Sandbox page under
/sandbox/<block>, linked from the catalog - Block spec in
e2e/sandbox/<block>.spec.ts, green with a fresh user - Only now may real app UI consume the block