194 lines
5.9 KiB
TypeScript
194 lines
5.9 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
applyCommand,
|
|
createRegistry,
|
|
decodeClipboard,
|
|
defineBlock,
|
|
defineDocumentKind,
|
|
duplicateNodes,
|
|
encodeClipboard,
|
|
json,
|
|
object,
|
|
record,
|
|
resolveDocumentVariables,
|
|
safeUrl,
|
|
string,
|
|
validateDocument,
|
|
type BlockDocument,
|
|
} from "../src/index.ts";
|
|
|
|
let serial = 0;
|
|
const id = () => `id-${++serial}`;
|
|
const styles = record(json) as never;
|
|
const text = defineBlock({
|
|
manifest: {
|
|
type: "content.text",
|
|
version: 1,
|
|
name: { key: "text", fallback: "Text" },
|
|
icon: "text",
|
|
category: "content",
|
|
capabilities: ["content"],
|
|
props: object({ text: string({ max: 20 }) }) as never,
|
|
styles,
|
|
slots: {},
|
|
},
|
|
create: ({ id }) => ({
|
|
id: id(),
|
|
type: "content.text",
|
|
version: 1,
|
|
props: { text: "" },
|
|
styles: {},
|
|
slots: {},
|
|
}),
|
|
renderers: { html: ({ node }) => String(node.props.text) },
|
|
textRenderer: ({ node }) => String(node.props.text),
|
|
});
|
|
const root = defineBlock({
|
|
manifest: {
|
|
type: "layout.root",
|
|
version: 1,
|
|
name: { key: "root", fallback: "Root" },
|
|
icon: "root",
|
|
category: "layout",
|
|
props: object({}) as never,
|
|
styles,
|
|
slots: { content: { accepts: { capabilities: ["content"] }, maxChildren: 5 } },
|
|
},
|
|
create: ({ id }) => ({
|
|
id: id(),
|
|
type: "layout.root",
|
|
version: 1,
|
|
props: {},
|
|
styles: {},
|
|
slots: { content: [] },
|
|
}),
|
|
renderers: { html: ({ slots }) => slots.content },
|
|
textRenderer: ({ slots }) => slots.content,
|
|
});
|
|
const kind = defineDocumentKind({
|
|
kind: "test",
|
|
version: 1,
|
|
metadata: object({ title: string() }) as never,
|
|
rootTypes: ["layout.root"],
|
|
blocks: [root, text],
|
|
variables: { recipientName: "string" },
|
|
breakpoints: {},
|
|
capabilities: [],
|
|
targets: ["html"],
|
|
limits: {
|
|
maxBytes: 10_000,
|
|
maxNodes: 20,
|
|
maxDepth: 5,
|
|
maxChildrenPerSlot: 5,
|
|
maxStringLength: 100,
|
|
maxOutputBytes: 1_000,
|
|
},
|
|
urlSchemes: ["https"],
|
|
});
|
|
const registry = createRegistry(kind.blocks).freeze();
|
|
const doc = (): BlockDocument => ({
|
|
schemaVersion: 1,
|
|
kind: "test",
|
|
meta: { title: "x" },
|
|
theme: { tokens: {} },
|
|
root: {
|
|
id: "root",
|
|
type: "layout.root",
|
|
version: 1,
|
|
props: {},
|
|
styles: {},
|
|
slots: { content: [] },
|
|
},
|
|
});
|
|
|
|
describe("dynamic blocks engine", () => {
|
|
test("rejects duplicate registrations and freezes registries", () => {
|
|
expect(() => createRegistry([text, text])).toThrow(/Duplicate block/);
|
|
const frozen = createRegistry([text]).freeze();
|
|
expect(() => (frozen as never as { register(value: unknown): void }).register(text)).toThrow();
|
|
});
|
|
|
|
test("validates named slots and applies invertible commands", () => {
|
|
const node = text.create({ id });
|
|
node.props.text = "Hello";
|
|
const inserted = applyCommand(
|
|
doc(),
|
|
{ type: "insert", location: { parentId: "root", slot: "content", index: 0 }, nodes: [node] },
|
|
kind,
|
|
registry,
|
|
);
|
|
expect(inserted.ok).toBe(true);
|
|
if (!inserted.ok) return;
|
|
expect(inserted.value.document.root.slots.content[0]?.props.text).toBe("Hello");
|
|
const undone = applyCommand(inserted.value.document, inserted.value.inverse, kind, registry);
|
|
expect(undone.ok && undone.value.document.root.slots.content).toEqual([]);
|
|
});
|
|
|
|
test("rejects cycles and invalid slots", () => {
|
|
const bad = doc();
|
|
bad.root.slots.missing = [];
|
|
expect(validateDocument(bad, kind, registry).ok).toBe(false);
|
|
});
|
|
|
|
test("clipboard is deterministic and remaps ids", () => {
|
|
const node = text.create({ id });
|
|
node.props.text = "Hello";
|
|
const encoded = encodeClipboard("test", [node]);
|
|
expect(encoded).toBe(encodeClipboard("test", [node]));
|
|
const decoded = decodeClipboard(encoded, id);
|
|
expect(decoded.ok && decoded.value.roots[0]?.id).not.toBe(node.id);
|
|
expect(duplicateNodes([node], id)[0]?.props).toEqual(node.props);
|
|
});
|
|
|
|
test("moves forward within one slot without an off-by-one error", () => {
|
|
const document = doc();
|
|
document.root.slots.content = ["a", "b", "c"].map((nodeId) => ({
|
|
...text.create({ id: () => nodeId }),
|
|
props: { text: nodeId },
|
|
}));
|
|
const moved = applyCommand(document, {
|
|
type: "move",
|
|
intent: {
|
|
nodeIds: ["a"],
|
|
from: [{ parentId: "root", slot: "content", index: 0 }],
|
|
to: { parentId: "root", slot: "content", index: 3 },
|
|
},
|
|
}, kind, registry);
|
|
expect(moved.ok && moved.value.document.root.slots.content.map((node) => node.id)).toEqual([
|
|
"b", "c", "a",
|
|
]);
|
|
});
|
|
|
|
test("duplicates blocks with deterministic injected ids", () => {
|
|
const document = doc();
|
|
document.root.slots.content = [{ ...text.create({ id: () => "a" }), props: { text: "A" } }];
|
|
const duplicated = applyCommand(document, {
|
|
type: "duplicate",
|
|
nodeIds: ["a"],
|
|
ids: ["a-copy"],
|
|
}, kind, registry);
|
|
expect(duplicated.ok && duplicated.value.document.root.slots.content.map((node) => node.id)).toEqual([
|
|
"a", "a-copy",
|
|
]);
|
|
});
|
|
|
|
test("validates and resolves typed personalization tokens", () => {
|
|
const document = doc();
|
|
document.root.slots.content = [
|
|
{ ...text.create({ id: () => "personalized" }), props: { text: "Hi {{recipientName}}" } },
|
|
];
|
|
expect(validateDocument(document, kind, registry).ok).toBe(true);
|
|
const resolved = resolveDocumentVariables(document, kind.variables, { recipientName: "Ada" });
|
|
expect(resolved.ok && resolved.value.root.slots.content[0]?.props.text).toBe("Hi Ada");
|
|
document.root.slots.content[0]!.props.text = "{{notAllowed}}";
|
|
expect(validateDocument(document, kind, registry).ok).toBe(false);
|
|
});
|
|
|
|
test("safe URLs allow bounded variable templates without allowing unsafe schemes", () => {
|
|
const schema = safeUrl({ schemes: ["https"] });
|
|
expect(schema.parse("https://example.com/u/{{recipientName}}").ok).toBe(true);
|
|
expect(schema.parse("{{unsubscribeUrl}}").ok).toBe(true);
|
|
expect(schema.parse("javascript:alert(1)").ok).toBe(false);
|
|
});
|
|
});
|