Testing your layout
Unit-test the model in Node through its commands and middleware, and drive the rendered layout with Playwright through data-layout-path.
Two levels: the model is plain TypeScript with no DOM, so layout logic (the commands your UI
runs, your middleware rules, your listeners) is unit-testable in Node, without a browser. The
rendered layout needs a real browser (it measures the DOM), and every primitive's
data-layout-path gives your end-to-end tests stable selectors.
The examples on this site are tested with exactly these techniques. Try the drags by hand in the drag and drop example before scripting them.
Unit tests: the model
Build a model from a JSON v1 document with createModel, change it with commands, and assert on
the result and the state. Nothing needs a DOM, a React tree or an engine:
import { createModel, type LayoutJson } from "@fragiola/dockable-react";
import { expect, it } from "vitest";
type Types = { tabs: { note: undefined } };
const json: LayoutJson<Types> = {
version: 1,
root: {
type: "row",
children: [
{ type: "tabset", id: "left", children: [{ id: "notes", component: "note", label: "Notes" }] },
{ type: "tabset", id: "right", children: [{ id: "todo", component: "note", label: "Todo" }] },
],
},
};
it("moves a tab into the other tabset", () => {
const model = createModel<Types>(json);
const result = model.run("tab.move", { tabId: "notes", to: "right" });
expect(result).toEqual({ ok: true, value: { tabId: "notes" } });
expect(model.get("node-parent-by", { nodeId: "notes" })?.id).toBe("right");
expect(model.get("selected-tab-by", { tabsetId: "right" })?.id).toBe("notes");
});Give the nodes you assert on explicit ids in the test JSON: generated ids depend on creation
order. What to assert on:
-
the result:
runreturns{ ok: true, value }or{ ok: false, error }, and never throws on bad input, so a refusal is an assertion, not atry:const refused = model.run("tab.move", { tabId: "notes", to: "nowhere" }); expect(refused.ok).toBe(false); if (!refused.ok) expect(refused.error).toMatchObject({ code: "not_found", path: "/to" }); -
the queries:
model.get("node-by","node-parent-by","selected-tab-by","active-tabset","all-tabs","tabsets") andmodel.is("tab-selected","node-hidden-by-maximize", …) read the current state; -
the state:
model.stateis a plain, immutable object tree (state.root,state.borders,state.windows,state.active), sotoEqualandtoMatchObjectwork on any part of it; -
the document:
model.get("layout-json")is the whole layout as JSON v1, a convenient snapshot (expect(model.get("layout-json")).toMatchSnapshot()), andcreateModel(model.get("layout-json"))rebuilds the same state.
model.can(command, payload) answers whether run would succeed (a boolean), and
model.check(command, payload) returns what it would return, both without changing anything: test
the questions your UI asks (is this tab closeable, may this tabset maximize) the same way.
it("refuses to close a pinned tab", () => {
const model = createModel<Types>(json);
model.run("tab.pin", { tabId: "notes", value: true });
expect(model.can("tab.close", { tabId: "notes" })).toBe(false);
expect(model.check("tab.close", { tabId: "notes" })).toMatchObject({ ok: false, error: { code: "refused" } });
expect(model.get("all-tabs").map((tab) => tab.id)).toContain("notes"); // nothing changed
});A layout document from outside (a file, a server) is checked the same way: createModel throws a
LayoutValidationError whose issues list every problem with its JSON path, and
validateLayout(json) returns them without throwing.
Middleware and listeners
A middleware is a plain function of the model: install it on a test model with model.use and
run the commands it guards. The result says whether it vetoed:
import { type Middleware, veto } from "@fragiola/dockable-react";
// the app's rule: the notes tab is never closed
const keepNotes: Middleware<Types> = (ctx, next) =>
ctx.command === "tab.close" && ctx.payload.tabId === "notes" ? veto("Notes stays open") : next();
it("vetoes closing the notes", () => {
const model = createModel<Types>(json);
model.use(keepNotes);
expect(model.run("tab.close", { tabId: "notes" })).toMatchObject({
ok: false,
error: { code: "vetoed", message: "Notes stays open" },
});
expect(model.run("tab.close", { tabId: "todo" }).ok).toBe(true); // other tabs still close
});model.subscribe receives one event per commit, with the command, its payload and result, the
state before and after, transient and meta. Collect them to test what your listeners (an
undo history, a save-on-change) see:
it("reports each commit", () => {
const model = createModel<Types>(json);
const events: string[] = [];
const stop = model.subscribe((event) => events.push(event.command));
model.run("tab.select", { tabId: "todo" });
model.run("batch", {
commands: [
{ command: "tab.close", payload: { tabId: "notes" } },
{ command: "tab.close", payload: { tabId: "todo" } },
],
});
stop();
expect(events).toEqual(["tab.select", "batch"]); // a batch is one event
});The engine and the React parts issue the very same commands, so a rule tested this way holds for drags, keys and buttons too.
Component tests (jsdom)
jsdom has no layout: every rect is zero, so panels never get a size and Dockable.Panels
renders no content. Component tests can check the structure (tabs, data-selected, ARIA) and
the commands your buttons run (read them back from model.subscribe, or assert on the model's
state), but not geometry or drag and drop. Use a browser for those.
End-to-end tests: layout paths
Every primitive carries data-layout-path (see Layout paths):
import { expect, type Locator, type Page, test } from "@playwright/test";
const at = (page: Page, path: string) => page.locator(`[data-layout-path="${path}"]`);
test("selects a tab", async ({ page }) => {
await page.goto("/");
await at(page, "/ts0/tb1").click();
await expect(at(page, "/ts0/tb1")).toHaveAttribute("data-selected", "");
await expect(at(page, "/ts0/t1")).toBeVisible();
});Wait for the layout before interacting: await expect(at(page, "/layout")).toBeVisible().
Dragging a tab
Tabs use native HTML5 drag and drop. In Chromium, drive it with the mouse: press, move a little
so dragstart fires, move to the target, then nudge by a pixel so the last dragover reports
the final point (a single step that crosses into another element can fire only
dragenter/dragleave):
async function dragTo(page: Page, from: Locator, to: { x: number; y: number }) {
const box = await from.boundingBox();
if (!box) throw new Error("no box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 10, box.y + box.height / 2 + 10);
await page.mouse.move(to.x, to.y, { steps: 10 });
await page.mouse.move(to.x + 1, to.y);
await page.mouse.move(to.x, to.y);
await page.mouse.up();
}
test("drops a tab into the other tabset", async ({ page }) => {
await page.goto("/");
const target = await at(page, "/ts1/content").boundingBox();
await dragTo(page, at(page, "/ts0/tb0"), { x: target!.x + target!.width / 2, y: target!.y + target!.height / 2 });
await expect(page.getByRole("tab", { name: "Notes" })).toHaveAttribute("data-layout-path", /^\/ts1\//);
});While the drag is in progress, /outline (the drop indicator) has data-visible and a
data-drop-location to assert on.
Splitters
Drag the splitter with the mouse, or focus it and press the arrow keys (10px per press). Assert
on aria-valuenow or on the tabsets' sizes:
await at(page, "/s0").focus();
await page.keyboard.press("ArrowRight");
await expect(at(page, "/s0")).toHaveAttribute("aria-valuetext", /%$/);Popouts
A popout is a new page of the browser context:
const [popup] = await Promise.all([
page.context().waitForEvent("page"),
page.getByRole("button", { name: /pop out/i }).click(),
]);
await expect(popup.locator('[data-layout-path="/sublayout1/ts0/tb0"]')).toBeVisible();Tips
- Paths follow the structure: after a move, assert on the new path, or find tabs by role and name.
- Avoid class-name selectors: they are yours and will change; paths and roles will not.
- Native drags are timing-sensitive: keep the per-test worker count moderate, and retry on CI.
Adapted from FlexLayout's Testing your layout (Caplin Systems Ltd, MIT); its Playwright suite is built on the same paths.