Skip to content
Tikab's Toolkit

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...
  1. The package — the block lives in packages/<block> with a browser stub and its own typecheck.
  2. The sandbox page — a route under /sandbox that mounts the block through its real seam (server function → package → infrastructure) with minimal chrome and stable data-testids. The whole /sandbox subtree requires site admin on production builds.
  3. 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.)
  4. 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 up plus 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

TierWhereThe question it answers
Journey testse2e/*.spec.tsDoes the app do the right thing? Full UI, seeded data, flows
Block testse2e/sandbox/*.spec.tsDoes 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.ts module and import it back into the sandbox. (That is how createWorkspace became 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