docs/slate-browser/api-design.md
Specialist testing/proof doc. For current queue and roadmap truth, see master-roadmap.md.
This doc proposes the API/DX shape for the future editor testing framework.
Strong take:
If the framework is for editor work, its nouns should be:
Not:
import { openExample } from "slate-browser/playwright";
const editor = await openExample(page, "placeholder");
await editor.focus();
await editor.assert.selection({
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 0 },
});
await editor.ime.compose({
text: "가",
});
await editor.assert.text("가");
expect(await editor.get.selection()).not.toBeNull();
The exact syntax is not the point. The point is:
Concrete inspirations:
initialize(...)assertHTML(...)assertSelection(...)storyUrl(...)getEditable(...)getText(...)getSelection(...)The right API should steal their shape discipline, not their exact names.
import { openExample } from "slate-browser/playwright";
const editor = await openExample(page, "placeholder");
Responsibilities:
Why:
openFixture(...) is intentionally omitted until there is a real fixture laneCurrent options:
const editor = await openExample(page, "custom-placeholder", {
surface: {
frame: "iframe",
scope: '[data-cy="outer-shadow-root"] > div',
},
ready: {
editor: "visible",
placeholder: "visible",
},
});
await editor.selection.select({
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 5 },
});
await editor.selection.collapse({ path: [0, 0], offset: 5 });
const bookmark = await editor.selection.capture({ affinity: "inward" });
await editor.selection.restore(bookmark);
await editor.selection.unref(bookmark);
await editor.selection.selectAll();
const selection = await editor.selection.get();
const domSelection = await editor.selection.dom();
const rect = await editor.selection.rect();
await editor.focus();
await editor.click();
await editor.type("hello");
await editor.press("Enter");
Responsibilities:
This should wrap runner details.
It should not leak:
page.getByRole('textbox')The package now exposes a selection namespace because editor tests keep needing selection as both an action surface and a readable state surface.
const text = await editor.get.text();
const blockTexts = await editor.get.blockTexts();
const selectedText = await editor.get.selectedText();
const html = await editor.get.html();
const selection = await editor.get.selection();
const domSelection = await editor.get.domSelection();
const snapshot = await editor.snapshot();
await editor.assert.text("hello");
await editor.assert.blockTexts(["hello"]);
await editor.assert.htmlContains("<p>...</p>");
await editor.assert.htmlEquals("<p>...</p>", {
ignoreClasses: true,
ignoreInlineStyles: true,
ignoreDir: true,
});
await editor.assert.selection(expectedSelection);
await editor.assert.domSelection(expectedDomSelection);
await editor.assert.placeholderShape(expectedShape);
await editor.assert.placeholderVisible(true);
Why:
Recommended assertion data shapes:
type EditorSelectionSnapshot = {
anchor: { path: number[]; offset: number | [number, number] };
focus: { path: number[]; offset: number | [number, number] };
};
type DOMSelectionSnapshot = {
anchorNodeText?: string;
anchorOffset: number | [number, number];
focusNodeText?: string;
focusOffset: number | [number, number];
};
The framework should prefer:
Current public surface:
editor.get.text()editor.get.blockTexts()editor.get.selectedText()editor.get.html()editor.get.selection()editor.get.domSelection()editor.snapshot()editor.selection.select(...)editor.selection.collapse(...)editor.selection.capture(...)editor.selection.bookmark(...)editor.selection.resolve(...)editor.selection.restore(...)editor.selection.unref(...)editor.locator.block(...)editor.locator.text(...)editor.assert.blockTexts(...)editor.assert.htmlContains(...)editor.assert.htmlEquals(..., options?)await editor.ime.compose({
text: "すし",
steps: ["s", "す", "すし"],
});
Why:
This layer should hide:
Input.imeSetCompositionCurrent public surface:
editor.ime.enableKeyEvents()editor.ime.compose(...)await editor.clipboard.copy();
const payload = await editor.clipboard.copyPayload();
await editor.clipboard.pasteText("hello");
await editor.clipboard.pasteHtml("<p>hello</p>");
Why:
Current public position:
copy() and copyPayload() are inpasteText() and pasteHtml() are in through real clipboard write plus real
paste gestureCurrent public surface:
editor.clipboard.copy()editor.clipboard.copyPayload()editor.clipboard.pasteText(text)editor.clipboard.pasteHtml(html, plainText?)editor.clipboard.assert.textContains(text)editor.clipboard.assert.htmlContains(fragment)editor.clipboard.assert.htmlEquals(html)editor.clipboard.assert.types(types)Clipboard actions are serialized with exclusive clipboard access inside the Playwright harness, so parallel tests do not casually stomp each other.
import { inspectZeroWidthPlaceholder } from "slate-browser/browser";
await editor.assert.placeholderShape({
kind: "line-break",
hasBr: true,
hasFEFF: true,
});
Why:
Current package split:
slate-browserslate-browser/coreslate-browser/browserslate-browser/playwrightconst extended = editor.withExtension(agentDriver);
Why:
If/when added later, this extension seam should wrap:
It should not replace them.
The API should stay stable while the backend changes by lane.
slate-browser/
index.ts
core/
index.ts
selection.ts
browser/
index.ts
selection.ts
zero-width.ts
playwright/
index.ts
ime.ts
Bad:
await page.locator("div[contenteditable=true]").click();
await page.keyboard.type("hello");
Better:
await editor.focus();
await editor.type("hello");
Do not build:
EditorDriver that tries to hide every laneThe package does not expose openFixture(...) yet.
That distinction was attractive in theory and fake in practice.
Current public rule:
openExample(...) is the only routing entrypointThese are intentionally not part of the current public API:
openFixture(...)editor.driver()If any of these come back later, they need a real backing seam first.
Repo-local Playwright tests import slate-browser/playwright through the public
package exports.
That means the package must be built before those tests run.
Current repo contract:
yarn build:slate-browser:playwrightyarn test:slate-browser:e2eyarn test:slate-browser:imeyarn test:slate-browser:anchorsThe root commands already do this.
Repo-local browser tests import the built public package entrypoints directly. That is intentional. The package shape is the contract now.
Bad:
playwrightFocusEditorbrowserAssertRangecdpImeInsertTextBetter:
focusEditorassertSelectioneditor.ime.composeopenExample(name)editor.focus()editor.assert.selection(...)editor.assert.text(...)editor.ime.compose(...)editor.get.selection()editor.selection.selectAll()editor.clipboard.copyPayload()editor.clipboard.pasteText(...)editor.clipboard.pasteHtml(...)editor.assert.placeholderShape(...)editor.withExtension(extension)The best API is not “Playwright but renamed.”
It is: