feat: release design system engine, studio and component contracts

This commit is contained in:
Clintchiz committed 2026-10-19 13:09:03 +05:30
1 parent ff1065fb27
commit 3943b946d6
693 files changed
+175546 -3894

No files matched your search

+32 -10
View File
@@ -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);
});
+65 -13
View File
@@ -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>");
});
+49 -9
View File
@@ -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();
});
+20 -5
View File
@@ -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();
});
+19 -5
View File
@@ -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");
+53 -15
View File
@@ -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");
+24 -6
View File
@@ -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);
});