feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -2,18 +2,28 @@ import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Window } from "happy-dom";
|
||||
|
||||
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(readFileSync(new URL("../src/canvas.ts", import.meta.url), "utf8"))
|
||||
.replace(/^import .*;\s*$/gm, "").replace("export function mountBlockCanvas", "function mountBlockCanvas");
|
||||
const code = new Bun.Transpiler({ loader: "ts" })
|
||||
.transformSync(readFileSync(new URL("../src/canvas.ts", import.meta.url), "utf8"))
|
||||
.replace(/^import .*;\s*$/gm, "")
|
||||
.replace("export function mountBlockCanvas", "function mountBlockCanvas");
|
||||
|
||||
test("canvas annotates exact node locations and cleans editor decorations without changing content", () => {
|
||||
const window = new Window();
|
||||
const root = window.document.body;
|
||||
root.innerHTML = '<table data-block-id="root"><tbody><tr><td data-block-id="column" draggable="true"><table></table></td></tr></tbody></table>';
|
||||
root.innerHTML =
|
||||
'<table data-block-id="root"><tbody><tr><td data-block-id="column" draggable="true"><table></table></td></tr></tbody></table>';
|
||||
const original = root.innerHTML;
|
||||
const tree = { id: "root", type: "layout.email-document", slots: { content: [{ id: "column", type: "layout.column", slots: { content: [] } }] } };
|
||||
const tree = {
|
||||
id: "root",
|
||||
type: "layout.email-document",
|
||||
slots: { content: [{ id: "column", type: "layout.column", slots: { content: [] } }] },
|
||||
};
|
||||
const before = JSON.stringify(tree);
|
||||
let destroyed = 0;
|
||||
const mount = new Function("mountBlockEditor", code + ";return mountBlockCanvas;")(() => ({ destroy: () => destroyed++, refresh() {} }));
|
||||
const mount = new Function("mountBlockEditor", code + ";return mountBlockCanvas;")(() => ({
|
||||
destroy: () => destroyed++,
|
||||
refresh() {},
|
||||
}));
|
||||
const mounted = mount(root, tree, {});
|
||||
const column = root.querySelector('[data-block-id="column"]')!;
|
||||
expect(column.getAttribute("data-block-parent")).toBe("root");
|
||||
@@ -21,7 +31,8 @@ test("canvas annotates exact node locations and cleans editor decorations withou
|
||||
expect(column.getAttribute("draggable")).toBe("false");
|
||||
expect(root.querySelectorAll("[data-block-handle]")).toHaveLength(1);
|
||||
const handle = root.querySelector("[data-block-handle]")!;
|
||||
let clicks = 0, pointers = 0;
|
||||
let clicks = 0,
|
||||
pointers = 0;
|
||||
column.addEventListener("click", () => clicks++);
|
||||
column.addEventListener("dblclick", () => clicks++);
|
||||
column.addEventListener("pointerdown", () => pointers++);
|
||||
@@ -30,9 +41,12 @@ test("canvas annotates exact node locations and cleans editor decorations withou
|
||||
handle.dispatchEvent(new window.PointerEvent("pointerdown", { bubbles: true }));
|
||||
expect(clicks).toBe(0);
|
||||
expect(pointers).toBe(1);
|
||||
expect(root.querySelector("[data-block-slot-target]")?.getAttribute("data-block-parent")).toBe("column");
|
||||
expect(root.querySelector("[data-block-slot-target]")?.getAttribute("data-block-parent")).toBe(
|
||||
"column",
|
||||
);
|
||||
expect(JSON.stringify(tree)).toBe(before);
|
||||
mounted.destroy(); mounted.destroy();
|
||||
mounted.destroy();
|
||||
mounted.destroy();
|
||||
expect(destroyed).toBe(1);
|
||||
expect(root.innerHTML).toBe(original);
|
||||
});
|
||||
@@ -41,7 +55,15 @@ test("failed canvas mounting rolls back annotations", () => {
|
||||
const root = new Window().document.body;
|
||||
root.innerHTML = '<div data-block-id="root"><div data-block-id="child">Content</div></div>';
|
||||
const before = root.innerHTML;
|
||||
const mount = new Function("mountBlockEditor", code + ";return mountBlockCanvas;")(() => { throw new Error("backend unavailable"); });
|
||||
expect(() => mount(root, { id: "root", type: "root", slots: { content: [{ id: "child", type: "text", slots: {} }] } }, {})).toThrow("backend unavailable");
|
||||
const mount = new Function("mountBlockEditor", code + ";return mountBlockCanvas;")(() => {
|
||||
throw new Error("backend unavailable");
|
||||
});
|
||||
expect(() =>
|
||||
mount(
|
||||
root,
|
||||
{ id: "root", type: "root", slots: { content: [{ id: "child", type: "text", slots: {} }] } },
|
||||
{},
|
||||
),
|
||||
).toThrow("backend unavailable");
|
||||
expect(root.innerHTML).toBe(before);
|
||||
});
|
||||
@@ -1,23 +1,75 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const source = new Bun.Transpiler({ loader: "ts" }).transformSync(readFileSync(new URL("../src/download.ts", import.meta.url), "utf8")).replace("export function", "function");
|
||||
const source = new Bun.Transpiler({ loader: "ts" })
|
||||
.transformSync(readFileSync(new URL("../src/download.ts", import.meta.url), "utf8"))
|
||||
.replace("export function", "function");
|
||||
|
||||
test("download keeps direct link after blocked automatic click and releases replaced/detached blobs", () => {
|
||||
const revoked: string[] = [], observers: Array<{ callback(): void; disconnect(): void }> = [];
|
||||
let sequence = 0, clicks = 0, disconnected = 0;
|
||||
const revoked: string[] = [],
|
||||
observers: Array<{ callback(): void; disconnect(): void }> = [];
|
||||
let sequence = 0,
|
||||
clicks = 0,
|
||||
disconnected = 0;
|
||||
const links: any[] = [];
|
||||
const outlet = { replaceChildren: (link: unknown) => { links.splice(0, links.length, link); } };
|
||||
const host = { isConnected: true, querySelector: () => outlet, ownerDocument: { documentElement: {}, createElement: () => ({ href: "", download: "", textContent: "", click() { clicks++; throw new Error("blocked"); }, remove() { const index = links.indexOf(this); if (index >= 0) links.splice(index, 1); } }) } };
|
||||
const offer = new Function("URL", "Blob", "MutationObserver", source + ";return offerBuilderDownload;")(
|
||||
{ createObjectURL: () => `blob:${++sequence}`, revokeObjectURL: (url: string) => revoked.push(url) }, Blob,
|
||||
class { constructor(public callback: () => void) { observers.push(this); } observe() {} disconnect() { disconnected++; } },
|
||||
const outlet = {
|
||||
replaceChildren: (link: unknown) => {
|
||||
links.splice(0, links.length, link);
|
||||
},
|
||||
};
|
||||
const host = {
|
||||
isConnected: true,
|
||||
querySelector: () => outlet,
|
||||
ownerDocument: {
|
||||
documentElement: {},
|
||||
createElement: () => ({
|
||||
href: "",
|
||||
download: "",
|
||||
textContent: "",
|
||||
click() {
|
||||
clicks++;
|
||||
throw new Error("blocked");
|
||||
},
|
||||
remove() {
|
||||
const index = links.indexOf(this);
|
||||
if (index >= 0) links.splice(index, 1);
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
const offer = new Function(
|
||||
"URL",
|
||||
"Blob",
|
||||
"MutationObserver",
|
||||
source + ";return offerBuilderDownload;",
|
||||
)(
|
||||
{
|
||||
createObjectURL: () => `blob:${++sequence}`,
|
||||
revokeObjectURL: (url: string) => revoked.push(url),
|
||||
},
|
||||
Blob,
|
||||
class {
|
||||
constructor(public callback: () => void) {
|
||||
observers.push(this);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {
|
||||
disconnected++;
|
||||
}
|
||||
},
|
||||
);
|
||||
offer(host, "html", "text/html", "draft.html");
|
||||
expect(links[0].href).toBe("blob:1"); expect(links[0].download).toBe("draft.html");
|
||||
expect(revoked).toEqual([]); expect(clicks).toBe(1);
|
||||
expect(links[0].href).toBe("blob:1");
|
||||
expect(links[0].download).toBe("draft.html");
|
||||
expect(revoked).toEqual([]);
|
||||
expect(clicks).toBe(1);
|
||||
offer(host, "text", "text/plain", "draft.txt");
|
||||
expect(revoked).toEqual(["blob:1"]); expect(links[0].download).toBe("draft.txt");
|
||||
host.isConnected = false; observers[1]!.callback(); observers[1]!.callback();
|
||||
expect(revoked).toEqual(["blob:1", "blob:2"]); expect(links).toEqual([]); expect(disconnected).toBe(2);
|
||||
expect(revoked).toEqual(["blob:1"]);
|
||||
expect(links[0].download).toBe("draft.txt");
|
||||
host.isConnected = false;
|
||||
observers[1]!.callback();
|
||||
observers[1]!.callback();
|
||||
expect(revoked).toEqual(["blob:1", "blob:2"]);
|
||||
expect(links).toEqual([]);
|
||||
expect(disconnected).toBe(2);
|
||||
});
|
||||
@@ -3,24 +3,39 @@ import { showImageFallbacks } from "../src/image-preview.ts";
|
||||
|
||||
test("image-disabled mode exposes safe alt text, proportions and reversible placeholders", () => {
|
||||
const displayed: any[] = [];
|
||||
const images = [{ alt: '<b>Logo & name</b>', width: '320', height: '180' }, { alt: '', width: 'not a size', height: '99999' }].map(attributes => ({
|
||||
const images = [
|
||||
{ alt: "<b>Logo & name</b>", width: "320", height: "180" },
|
||||
{ alt: "", width: "not a size", height: "99999" },
|
||||
].map((attributes) => ({
|
||||
getAttribute: (key: string) => (attributes as Record<string, string>)[key] ?? null,
|
||||
after: (element: unknown) => displayed.push(element),
|
||||
}));
|
||||
const canvas = {
|
||||
querySelectorAll: () => images,
|
||||
createElement: () => ({ dataset: {}, style: {} as Record<string, string>, textContent: "", attributes: {} as Record<string, string>, setAttribute(key: string, value: string) { this.attributes[key] = value; }, remove() { const index = displayed.indexOf(this); if (index >= 0) displayed.splice(index, 1); } }),
|
||||
createElement: () => ({
|
||||
dataset: {},
|
||||
style: {} as Record<string, string>,
|
||||
textContent: "",
|
||||
attributes: {} as Record<string, string>,
|
||||
setAttribute(key: string, value: string) {
|
||||
this.attributes[key] = value;
|
||||
},
|
||||
remove() {
|
||||
const index = displayed.indexOf(this);
|
||||
if (index >= 0) displayed.splice(index, 1);
|
||||
},
|
||||
}),
|
||||
} as unknown as Document;
|
||||
showImageFallbacks(canvas, true);
|
||||
expect(displayed).toHaveLength(2);
|
||||
expect(displayed[0].textContent).toBe('<b>Logo & name</b>');
|
||||
expect(displayed[0].attributes['aria-label']).toBe('<b>Logo & name</b>');
|
||||
expect(displayed[0].style.aspectRatio).toBe('320/180');
|
||||
expect(displayed[1].attributes['aria-hidden']).toBe('true');
|
||||
expect(displayed[0].textContent).toBe("<b>Logo & name</b>");
|
||||
expect(displayed[0].attributes["aria-label"]).toBe("<b>Logo & name</b>");
|
||||
expect(displayed[0].style.aspectRatio).toBe("320/180");
|
||||
expect(displayed[1].attributes["aria-hidden"]).toBe("true");
|
||||
expect(displayed[1].style.maxWidth).toBeUndefined();
|
||||
showImageFallbacks(canvas, true);
|
||||
expect(displayed).toHaveLength(2);
|
||||
showImageFallbacks(canvas, false);
|
||||
expect(displayed).toHaveLength(0);
|
||||
expect(images[0]!.getAttribute('alt')).toBe('<b>Logo & name</b>');
|
||||
expect(images[0]!.getAttribute("alt")).toBe("<b>Logo & name</b>");
|
||||
});
|
||||
@@ -3,16 +3,43 @@ import { defineBlock, object, string } from "@wrnexus/blocks";
|
||||
import { createBuilderKit } from "../src/kit.ts";
|
||||
|
||||
const block = defineBlock({
|
||||
manifest: { type: "custom.note", version: 1, name: { key: "note", fallback: "Note" }, icon: "note", category: "content",
|
||||
props: object({ text: string({ min: 1 }) }), styles: object({}), slots: {} },
|
||||
create: ({ id }: { id(): string }) => ({ id: id(), type: "custom.note", version: 1, props: { text: "Hello" }, styles: {}, slots: {} }),
|
||||
manifest: {
|
||||
type: "custom.note",
|
||||
version: 1,
|
||||
name: { key: "note", fallback: "Note" },
|
||||
icon: "note",
|
||||
category: "content",
|
||||
props: object({ text: string({ min: 1 }) }),
|
||||
styles: object({}),
|
||||
slots: {},
|
||||
},
|
||||
create: ({ id }: { id(): string }) => ({
|
||||
id: id(),
|
||||
type: "custom.note",
|
||||
version: 1,
|
||||
props: { text: "Hello" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
}),
|
||||
});
|
||||
test("trusted custom blocks and presets expose independent instances and explicit metadata", () => {
|
||||
let next = 0; const context = { id: () => String(++next) };
|
||||
const kit = createBuilderKit({ blocks: [block], presets: [{ id: "note", name: { key: "preset.note", fallback: "Note" }, icon: "note", create: block.create }] });
|
||||
let next = 0;
|
||||
const context = { id: () => String(++next) };
|
||||
const kit = createBuilderKit({
|
||||
blocks: [block],
|
||||
presets: [
|
||||
{
|
||||
id: "note",
|
||||
name: { key: "preset.note", fallback: "Note" },
|
||||
icon: "note",
|
||||
create: block.create,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(kit.blocks()[0].manifest.name.fallback).toBe("Note");
|
||||
expect(kit.presets()[0].id).toBe("note");
|
||||
const first = kit.createBlock("custom.note", context), second = kit.createPreset("note", context);
|
||||
const first = kit.createBlock("custom.note", context),
|
||||
second = kit.createPreset("note", context);
|
||||
first.props.text = "Changed";
|
||||
expect(second.props.text).toBe("Hello");
|
||||
expect(first.id).not.toBe(second.id);
|
||||
@@ -22,10 +49,23 @@ test("trusted custom blocks and presets expose independent instances and explici
|
||||
});
|
||||
test("duplicate registration and invalid factories fail before insertion", () => {
|
||||
expect(() => createBuilderKit({ blocks: [block, block] })).toThrow();
|
||||
const preset = { id: "note", name: { key: "note", fallback: "Note" }, icon: "note", create: block.create };
|
||||
const preset = {
|
||||
id: "note",
|
||||
name: { key: "note", fallback: "Note" },
|
||||
icon: "note",
|
||||
create: block.create,
|
||||
};
|
||||
expect(() => createBuilderKit({ blocks: [block], presets: [preset, preset] })).toThrow();
|
||||
const invalid = createBuilderKit({ blocks: [{ ...block, create: () => ({ ...block.create({ id: () => "id" }), props: { text: "" } }) }] });
|
||||
const invalid = createBuilderKit({
|
||||
blocks: [
|
||||
{ ...block, create: () => ({ ...block.create({ id: () => "id" }), props: { text: "" } }) },
|
||||
],
|
||||
});
|
||||
expect(() => invalid.createBlock("custom.note", { id: () => "id" })).toThrow();
|
||||
const wrong = createBuilderKit({ blocks: [{ ...block, create: () => ({ ...block.create({ id: () => "id" }), type: "custom.other" }) }] });
|
||||
const wrong = createBuilderKit({
|
||||
blocks: [
|
||||
{ ...block, create: () => ({ ...block.create({ id: () => "id" }), type: "custom.other" }) },
|
||||
],
|
||||
});
|
||||
expect(() => wrong.createBlock("custom.note", { id: () => "id" })).toThrow();
|
||||
});
|
||||
@@ -2,19 +2,34 @@ import { expect, test } from "bun:test";
|
||||
import type { BlockNode } from "@wrnexus/blocks";
|
||||
import { builderOutlineLabel } from "../src/outline.ts";
|
||||
|
||||
const node = (props: BlockNode["props"], type = "content.button"): BlockNode => ({ id: "n", type, version: 1, props, styles: {}, slots: {} });
|
||||
const node = (props: BlockNode["props"], type = "content.button"): BlockNode => ({
|
||||
id: "n",
|
||||
type,
|
||||
version: 1,
|
||||
props,
|
||||
styles: {},
|
||||
slots: {},
|
||||
});
|
||||
|
||||
test("outline uses registered names and distinguishes content without mutating it", () => {
|
||||
const block = node({ label: " Buy\n now " });
|
||||
expect(builderOutlineLabel(block, { name: { fallback: "Button" } })).toBe("Button — Buy now");
|
||||
expect(block.props.label).toBe(" Buy\n now ");
|
||||
expect(builderOutlineLabel(node({ width: "35%" }, "layout.column"), { name: { fallback: "Column" } })).toBe("Column — 35%");
|
||||
expect(
|
||||
builderOutlineLabel(node({ width: "35%" }, "layout.column"), { name: { fallback: "Column" } }),
|
||||
).toBe("Column — 35%");
|
||||
expect(builderOutlineLabel(node({}, "custom.unknown"))).toBe("custom.unknown");
|
||||
});
|
||||
|
||||
test("outline bounds Unicode snippets and treats content as plain text", () => {
|
||||
const label = builderOutlineLabel(node({ label: "😀".repeat(65) }), { name: { fallback: "Button" } });
|
||||
const label = builderOutlineLabel(node({ label: "😀".repeat(65) }), {
|
||||
name: { fallback: "Button" },
|
||||
});
|
||||
expect(label).toBe("Button — " + "😀".repeat(64) + "…");
|
||||
expect(builderOutlineLabel(node({ label: '<img onerror="bad">' }))).toContain('<img onerror="bad">');
|
||||
expect(builderOutlineLabel(node({ text: {}, label: "", title: "Product title" }))).toBe("content.button — Product title");
|
||||
expect(builderOutlineLabel(node({ label: '<img onerror="bad">' }))).toContain(
|
||||
'<img onerror="bad">',
|
||||
);
|
||||
expect(builderOutlineLabel(node({ text: {}, label: "", title: "Product title" }))).toBe(
|
||||
"content.button — Product title",
|
||||
);
|
||||
});
|
||||
@@ -4,10 +4,15 @@ import { builderPreviewHTML } from "../src/preview-html.ts";
|
||||
|
||||
test("preview sanitization preserves email layout and editor markers but removes executable content", () => {
|
||||
const { window } = new JSDOM("");
|
||||
const result = String(builderPreviewHTML('<html><head><style>td{padding:12px}</style></head><body><table data-block-id="one"><tr><td><img src="https://example.com/a.png" onerror="alert(1)"><a href="javascript:alert(1)">Hello</a></td></tr></table><script>alert(1)</script><iframe src="https://example.com"></iframe></body></html>', window.document as unknown as Document));
|
||||
const result = String(
|
||||
builderPreviewHTML(
|
||||
'<html><head><style>td{padding:12px}</style></head><body><table data-block-id="one"><tr><td><img src="https://example.com/a.png" onerror="alert(1)"><a href="javascript:alert(1)">Hello</a></td></tr></table><script>alert(1)</script><iframe src="https://example.com"></iframe></body></html>',
|
||||
window.document as unknown as Document,
|
||||
),
|
||||
);
|
||||
expect(result).toContain('data-block-id="one"');
|
||||
expect(result).toContain('<table');
|
||||
expect(result).toContain('<style>');
|
||||
expect(result).toContain("<table");
|
||||
expect(result).toContain("<style>");
|
||||
expect(result).not.toMatch(/onerror|javascript:|<script|<iframe/);
|
||||
window.close();
|
||||
});
|
||||
@@ -20,7 +25,9 @@ test("separately bundled preview modules reuse one realm sanitizer", async () =>
|
||||
const first = (window as any)[key];
|
||||
const secondPageModule = "../src/preview-html.ts?second-page";
|
||||
const secondModule = await import(secondPageModule);
|
||||
expect(String(secondModule.builderPreviewHTML("<p>Second page</p>", doc))).toContain("Second page");
|
||||
expect(String(secondModule.builderPreviewHTML("<p>Second page</p>", doc))).toContain(
|
||||
"Second page",
|
||||
);
|
||||
expect((window as any)[key]).toBe(first);
|
||||
window.close();
|
||||
});
|
||||
@@ -5,20 +5,34 @@ import { readBuilderText, mountInlineRichText } from "../src/rich-text.ts";
|
||||
test("inline rich text extracts only supported semantics", () => {
|
||||
const window = new Window();
|
||||
const element = window.document.createElement("p");
|
||||
element.innerHTML = 'Hello <strong>bold <em>and italic</em></strong><br><u>line</u><script>bad()</script>';
|
||||
expect(readBuilderText(element as unknown as HTMLElement)).toEqual([{text:"Hello "},{text:"bold ",bold:true},{text:"and italic",bold:true,italic:true},{text:"\n"},{text:"line",underline:true}]);
|
||||
element.innerHTML =
|
||||
"Hello <strong>bold <em>and italic</em></strong><br><u>line</u><script>bad()</script>";
|
||||
expect(readBuilderText(element as unknown as HTMLElement)).toEqual([
|
||||
{ text: "Hello " },
|
||||
{ text: "bold ", bold: true },
|
||||
{ text: "and italic", bold: true, italic: true },
|
||||
{ text: "\n" },
|
||||
{ text: "line", underline: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rich editor commits semantic input and cleans up on Done", () => {
|
||||
const window = new Window();
|
||||
const element = window.document.createElement("p");
|
||||
window.document.body.append(element);
|
||||
const updates: unknown[] = []; let finished = 0;
|
||||
mountInlineRichText(window.document as unknown as Document, element as unknown as HTMLElement, "hello", (text, runs) => updates.push({text,runs}), () => finished++);
|
||||
const updates: unknown[] = [];
|
||||
let finished = 0;
|
||||
mountInlineRichText(
|
||||
window.document as unknown as Document,
|
||||
element as unknown as HTMLElement,
|
||||
"hello",
|
||||
(text, runs) => updates.push({ text, runs }),
|
||||
() => finished++,
|
||||
);
|
||||
expect(element.contentEditable).toBe("true");
|
||||
element.innerHTML = "<strong>bold</strong>";
|
||||
element.dispatchEvent(new window.Event("input"));
|
||||
expect(updates).toEqual([{text:"bold",runs:[{text:"bold",bold:true}]}]);
|
||||
expect(updates).toEqual([{ text: "bold", runs: [{ text: "bold", bold: true }] }]);
|
||||
(window.document.querySelector('button[aria-label="Done"]') as any).click();
|
||||
expect(finished).toBe(1);
|
||||
expect(element.contentEditable).toBe("false");
|
||||
|
||||
@@ -11,22 +11,43 @@ const code = new Bun.Transpiler({ loader: "ts" })
|
||||
|
||||
test("partial registration failure cleans the backend and live region on mount and refresh", () => {
|
||||
for (const failingMount of [1, 2]) {
|
||||
const window = new Window(), root = window.document.createElement("div");
|
||||
const window = new Window(),
|
||||
root = window.document.createElement("div");
|
||||
window.document.body.append(root);
|
||||
root.innerHTML = '<div data-block-id="a" data-block-parent="root" data-block-slot="content"></div><div data-block-id="b" data-block-parent="root" data-block-slot="content"></div>';
|
||||
let mounts = 0, destroyed = 0, cleaned = 0;
|
||||
root.innerHTML =
|
||||
'<div data-block-id="a" data-block-parent="root" data-block-slot="content"></div><div data-block-id="b" data-block-parent="root" data-block-slot="content"></div>';
|
||||
let mounts = 0,
|
||||
destroyed = 0,
|
||||
cleaned = 0;
|
||||
const mount = new Function("createDndManager", code + ";return mountBlockEditor;")(() => {
|
||||
const number = ++mounts; let registrations = 0;
|
||||
return { registerSortable() { if (++registrations === 2 && number === failingMount) throw new Error("registration failed"); return () => { cleaned++; }; }, destroy() { destroyed++; } };
|
||||
const number = ++mounts;
|
||||
let registrations = 0;
|
||||
return {
|
||||
registerSortable() {
|
||||
if (++registrations === 2 && number === failingMount)
|
||||
throw new Error("registration failed");
|
||||
return () => {
|
||||
cleaned++;
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
destroyed++;
|
||||
},
|
||||
};
|
||||
});
|
||||
let editor: MountedEditor | undefined;
|
||||
if (failingMount === 1) expect(() => mount(root, { onMove() {} })).toThrow("registration failed");
|
||||
else { editor = mount(root, { onMove() {} }); expect(() => editor!.refresh()).toThrow("registration failed"); }
|
||||
if (failingMount === 1)
|
||||
expect(() => mount(root, { onMove() {} })).toThrow("registration failed");
|
||||
else {
|
||||
editor = mount(root, { onMove() {} });
|
||||
expect(() => editor!.refresh()).toThrow("registration failed");
|
||||
}
|
||||
expect(root.querySelector('[role="status"]')).toBeNull();
|
||||
expect(root.querySelectorAll('[data-block-id]')).toHaveLength(2);
|
||||
expect(root.querySelectorAll("[data-block-id]")).toHaveLength(2);
|
||||
expect(destroyed).toBe(failingMount);
|
||||
expect(cleaned).toBe(failingMount === 1 ? 1 : 3);
|
||||
editor?.destroy(); editor?.refresh();
|
||||
editor?.destroy();
|
||||
editor?.refresh();
|
||||
expect(destroyed).toBe(failingMount);
|
||||
}
|
||||
});
|
||||
@@ -46,7 +67,12 @@ function fixture(onMove: MountEditorOptions["onMove"] = () => false) {
|
||||
}> = [];
|
||||
const mount = new Function("createDndManager", code + ";return mountBlockEditor;")(
|
||||
(options: DndManagerOptions) => {
|
||||
const backend = { options, registrations: [] as SortableRegistration[], zones: [] as DropZoneRegistration[], destroyed: 0 };
|
||||
const backend = {
|
||||
options,
|
||||
registrations: [] as SortableRegistration[],
|
||||
zones: [] as DropZoneRegistration[],
|
||||
destroyed: 0,
|
||||
};
|
||||
backends.push(backend);
|
||||
return {
|
||||
registerDropZone: (registration: DropZoneRegistration) => {
|
||||
@@ -100,9 +126,15 @@ test("large canvas registration scans blocks once and preserves interleaved slot
|
||||
const backend = f.backends.at(-1)!;
|
||||
expect(scans).toBe(1);
|
||||
expect(backend.registrations.map(({ id, group, index }) => ({ id, group, index }))).toEqual(
|
||||
Array.from({ length: 300 }, (_, i) => ({ id: String(i), group: JSON.stringify([`parent:${i % 3}`, "content"]), index: Math.floor(i / 3) })),
|
||||
Array.from({ length: 300 }, (_, i) => ({
|
||||
id: String(i),
|
||||
group: JSON.stringify([`parent:${i % 3}`, "content"]),
|
||||
index: Math.floor(i / 3),
|
||||
})),
|
||||
);
|
||||
expect(backend.zones.map(zone => zone.group)).toEqual([JSON.stringify(["parent:3", "content"])]);
|
||||
expect(backend.zones.map((zone) => zone.group)).toEqual([
|
||||
JSON.stringify(["parent:3", "content"]),
|
||||
]);
|
||||
f.editor.destroy();
|
||||
});
|
||||
|
||||
@@ -127,7 +159,10 @@ test("cancelled optimistic moves restore original DOM nodes across containers",
|
||||
|
||||
test("empty named slot targets produce validated editor move intents and refresh away when populated", async () => {
|
||||
const moves: unknown[] = [];
|
||||
const f = fixture(intent => { moves.push(intent); return true; });
|
||||
const f = fixture((intent) => {
|
||||
moves.push(intent);
|
||||
return true;
|
||||
});
|
||||
const target = f.document.createElement("div");
|
||||
target.dataset.blockParent = "parent:empty";
|
||||
target.dataset.blockSlotTarget = "content:empty";
|
||||
@@ -138,9 +173,12 @@ test("empty named slot targets produce validated editor move intents and refresh
|
||||
const zone = backend.zones[0]!;
|
||||
expect(backend.options.accept!(["a"], { group: zone.group, index: 0 })).toBe(true);
|
||||
await backend.options.onMoveIntent({ ids: ["a"], from: [], to: { group: zone.group, index: 0 } });
|
||||
expect(moves).toEqual([{ nodeIds: ["a"], to: { parentId: "parent:empty", slot: "content:empty", index: 0 } }]);
|
||||
expect(moves).toEqual([
|
||||
{ nodeIds: ["a"], to: { parentId: "parent:empty", slot: "content:empty", index: 0 } },
|
||||
]);
|
||||
const child = f.root.querySelector('[data-block-id="a"]')! as any;
|
||||
child.dataset.blockParent = "parent:empty"; child.dataset.blockSlot = "content:empty";
|
||||
child.dataset.blockParent = "parent:empty";
|
||||
child.dataset.blockSlot = "content:empty";
|
||||
target.append(child);
|
||||
f.editor.refresh();
|
||||
expect(f.backends.at(-1)!.zones).toHaveLength(0);
|
||||
|
||||
@@ -3,10 +3,16 @@ import { Window } from "happy-dom";
|
||||
import { showBuilderBlockActions } from "../src/live-canvas.ts";
|
||||
|
||||
test("compact selection actions retain accessible names and callbacks without interpreting labels", () => {
|
||||
const window = new Window(), document = window.document;
|
||||
const window = new Window(),
|
||||
document = window.document;
|
||||
document.body.innerHTML = '<div data-block-id="text">Content</div>';
|
||||
let calls = 0;
|
||||
showBuilderBlockActions(document as unknown as Document, "text", "Text — <script>example</script>", { Edit: () => calls++, Delete: () => calls++ });
|
||||
showBuilderBlockActions(
|
||||
document as unknown as Document,
|
||||
"text",
|
||||
"Text — <script>example</script>",
|
||||
{ Edit: () => calls++, Delete: () => calls++ },
|
||||
);
|
||||
const toolbar = document.querySelector('[role="toolbar"]')!;
|
||||
expect(toolbar.getAttribute("aria-label")).toBe("Text — <script>example</script> actions");
|
||||
expect(toolbar.querySelector("strong")?.textContent).toBe("Text");
|
||||
|
||||
@@ -6,13 +6,29 @@ test("unsaved guard protects unload and ordinary navigation, permits safe links,
|
||||
const window = new Window({ url: "https://email.example.com/builder/one" });
|
||||
const root = window.document.createElement("main");
|
||||
window.document.body.append(root);
|
||||
let dirty = true, allow = false, confirmations = 0;
|
||||
const guard = mountUnsavedChangesGuard(root as unknown as HTMLElement, { hasUnsavedChanges: () => dirty, confirmLeave: () => { confirmations++; return allow; } });
|
||||
const unload = () => { const event = new window.Event("beforeunload", { cancelable: true }); window.dispatchEvent(event); return event.defaultPrevented; };
|
||||
let dirty = true,
|
||||
allow = false,
|
||||
confirmations = 0;
|
||||
const guard = mountUnsavedChangesGuard(root as unknown as HTMLElement, {
|
||||
hasUnsavedChanges: () => dirty,
|
||||
confirmLeave: () => {
|
||||
confirmations++;
|
||||
return allow;
|
||||
},
|
||||
});
|
||||
const unload = () => {
|
||||
const event = new window.Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
const click = (href: string, target = "") => {
|
||||
const link = window.document.createElement("a"); link.href = href; link.target = target; root.append(link);
|
||||
const link = window.document.createElement("a");
|
||||
link.href = href;
|
||||
link.target = target;
|
||||
root.append(link);
|
||||
const event = new window.MouseEvent("click", { bubbles: true, cancelable: true });
|
||||
link.dispatchEvent(event); return event.defaultPrevented;
|
||||
link.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
expect(unload()).toBe(true);
|
||||
expect(click("/builder/two")).toBe(true);
|
||||
@@ -23,6 +39,8 @@ test("unsaved guard protects unload and ordinary navigation, permits safe links,
|
||||
expect(click("/builder/two")).toBe(false);
|
||||
dirty = false;
|
||||
expect(unload()).toBe(false);
|
||||
guard.destroy(); guard.destroy(); dirty = true;
|
||||
guard.destroy();
|
||||
guard.destroy();
|
||||
dirty = true;
|
||||
expect(unload()).toBe(false);
|
||||
});
|
||||
Reference in new issue
Block a user