From fc1ab092d06b945d4fbcd3edeb0036a20b78ffc5 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 6 Oct 2026 12:04:17 +0200 Subject: [PATCH 1/7] test(rich-text-web): cover actions, attribute writes and render states Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/RichText.runtime.spec.tsx | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx new file mode 100644 index 0000000000..2c9475b971 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx @@ -0,0 +1,334 @@ +import "@testing-library/jest-dom"; +import { act, fireEvent, render, RenderResult, screen } from "@testing-library/react"; +import { Editor } from "@tiptap/core"; +import { actionValue, EditableValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { RichTextContainerProps, StatusBarContentEnum } from "../../typings/RichTextProps"; +import RichText from "../RichText"; + +const richTextDefaultValue = `

Rich text default value

`; + +function getEditorDom(container: HTMLElement): HTMLElement & { editor: Editor } { + return container.querySelector(".ProseMirror") as HTMLElement & { editor: Editor }; +} + +function getEditor(container: HTMLElement): Editor { + return getEditorDom(container).editor; +} + +function flush(): void { + act(() => { + jest.advanceTimersByTime(500); + }); +} + +function edit(container: HTMLElement, html: string): void { + act(() => { + getEditor(container).commands.setContent(html); + }); + flush(); +} + +describe("Rich Text runtime behaviour", () => { + let defaultProps: RichTextContainerProps; + + beforeEach(() => { + jest.useFakeTimers(); + defaultProps = { + name: "RichText", + id: "RichText1", + stringAttribute: new EditableValueBuilder().withValue(richTextDefaultValue).build(), + preset: "basic", + toolbarLocation: "bottom", + widthUnit: "percentage", + width: 100, + heightUnit: "percentageOfWidth", + height: 75, + toolbarConfig: "basic", + history: true, + fontStyle: true, + fontScript: true, + fontColor: true, + code: true, + indent: true, + embed: true, + align: true, + list: true, + remove: true, + header: true, + view: true, + tableBetter: false, + helpButton: true, + advancedConfig: [], + readOnlyStyle: "text", + tabIndex: 0, + onChangeType: "onLeave", + enableStatusBar: true, + dialogStyle: "inline", + statusBarContent: "wordCount" as StatusBarContentEnum, + spellCheck: true, + minHeightUnit: "none", + maxHeightUnit: "none", + maxHeight: 0, + minHeight: 75, + OverflowY: "auto", + customFonts: [], + enableDefaultUpload: true, + linkValidation: true, + styleDataFormat: "inline" + }; + }); + + afterEach(() => jest.useRealTimers()); + + describe("actions", () => { + // On load / On enter / On leave / On change are the widget's only hooks into app + // logic. Each must fire exactly on its trigger, respect canExecute, and follow the + // ActionValue objects the runtime passes on each render. + it("executes onLoad once on mount", () => { + const onLoad = actionValue(); + render(); + flush(); + + expect(onLoad.execute).toHaveBeenCalledTimes(1); + }); + + it("executes onFocus when the editor gains focus", () => { + const onFocus = actionValue(); + const { container } = render(); + + fireEvent.focus(getEditorDom(container)); + + expect(onFocus.execute).toHaveBeenCalledTimes(1); + }); + + it("executes onBlur when the editor loses focus", () => { + const onBlur = actionValue(); + const { container } = render(); + + fireEvent.focus(getEditorDom(container)); + fireEvent.blur(getEditorDom(container)); + + expect(onBlur.execute).toHaveBeenCalledTimes(1); + }); + + it("executes onChange on blur and not on edit when onChangeType is onLeave", () => { + const onChange = actionValue(); + const { container } = render(); + + edit(container, "

edited

"); + expect(onChange.execute).not.toHaveBeenCalled(); + + fireEvent.focus(getEditorDom(container)); + fireEvent.blur(getEditorDom(container)); + expect(onChange.execute).toHaveBeenCalledTimes(1); + }); + + it("executes onChange on edit and not on blur when onChangeType is onDataChange", () => { + const onChange = actionValue(); + const { container } = render( + + ); + + edit(container, "

edited

"); + expect(onChange.execute).toHaveBeenCalledTimes(1); + + fireEvent.focus(getEditorDom(container)); + fireEvent.blur(getEditorDom(container)); + expect(onChange.execute).toHaveBeenCalledTimes(1); + }); + + it("executes no action when canExecute is false", () => { + const onLoad = actionValue(false); + const onFocus = actionValue(false); + const onBlur = actionValue(false); + const onLeaveChange = actionValue(false); + const onDataChange = actionValue(false); + + const leave = render( + + ); + edit(leave.container, "

edited

"); + fireEvent.focus(getEditorDom(leave.container)); + fireEvent.blur(getEditorDom(leave.container)); + leave.unmount(); + + const data = render(); + edit(data.container, "

edited again

"); + + expect(onLoad.execute).not.toHaveBeenCalled(); + expect(onFocus.execute).not.toHaveBeenCalled(); + expect(onBlur.execute).not.toHaveBeenCalled(); + expect(onLeaveChange.execute).not.toHaveBeenCalled(); + expect(onDataChange.execute).not.toHaveBeenCalled(); + }); + + it("executes the ActionValues from the latest render", () => { + const first = { onFocus: actionValue(), onBlur: actionValue(), onChange: actionValue() }; + const second = { onFocus: actionValue(), onBlur: actionValue(), onChange: actionValue() }; + const { container, rerender } = render(); + + rerender(); + fireEvent.focus(getEditorDom(container)); + fireEvent.blur(getEditorDom(container)); + + expect(second.onFocus.execute).toHaveBeenCalledTimes(1); + expect(second.onBlur.execute).toHaveBeenCalledTimes(1); + expect(second.onChange.execute).toHaveBeenCalledTimes(1); + expect(first.onFocus.execute).not.toHaveBeenCalled(); + expect(first.onBlur.execute).not.toHaveBeenCalled(); + expect(first.onChange.execute).not.toHaveBeenCalled(); + }); + }); + + describe("writing to the attribute", () => { + // User edits are the only path into the bound attribute. The written value must be the + // editor's HTML, an empty editor must store "" (not an empty paragraph), unchanged + // content must not dirty the attribute, and writes must target the current attribute. + it("writes the editor HTML on edit", () => { + const stringAttribute = defaultProps.stringAttribute; + const { container } = render(); + + edit(container, "

edited

"); + + expect(stringAttribute.setValue).toHaveBeenCalledTimes(1); + expect(stringAttribute.setValue).toHaveBeenCalledWith(getEditor(container).getHTML()); + expect(stringAttribute.setValue).toHaveBeenCalledWith(expect.stringContaining("edited")); + }); + + it("writes an empty string when the editor is cleared", () => { + const stringAttribute = defaultProps.stringAttribute; + const { container } = render(); + + act(() => { + getEditor(container).commands.clearContent(true); + }); + flush(); + + expect(stringAttribute.setValue).toHaveBeenCalledTimes(1); + expect(stringAttribute.setValue).toHaveBeenCalledWith(""); + }); + + it("does not write when an edit produces the current value", () => { + const stringAttribute = defaultProps.stringAttribute; + const { container } = render(); + + edit(container, "

edited

"); + expect(stringAttribute.setValue).toHaveBeenCalledTimes(1); + + edit(container, "

edited

"); + expect(stringAttribute.setValue).toHaveBeenCalledTimes(1); + }); + + it("writes to the attribute from the latest render", () => { + const oldAttribute = defaultProps.stringAttribute; + const { container, rerender } = render(); + + edit(container, "

first

"); + expect(oldAttribute.setValue).toHaveBeenCalledTimes(1); + + const newAttribute = new EditableValueBuilder().withValue(oldAttribute.value).build(); + rerender(); + edit(container, "

second

"); + + expect(newAttribute.setValue).toHaveBeenCalledTimes(1); + expect(newAttribute.setValue).toHaveBeenCalledWith(expect.stringContaining("second")); + expect(oldAttribute.setValue).toHaveBeenCalledTimes(1); + }); + }); + + describe("attribute status", () => { + // The editor must only mount for an available attribute; loading shows a progress + // indicator, and validation feedback is shown regardless. + it("renders the loading indicator and no editor while loading", () => { + const { container } = render( + ().isLoading().build()} /> + ); + + expect(container.querySelector(".mx-progress")).toBeInTheDocument(); + expect(container.querySelector(".ProseMirror")).not.toBeInTheDocument(); + }); + + it("renders no editor when unavailable", () => { + const { container } = render( + ().isUnavailable().build()} + /> + ); + + expect(container.querySelector(".ProseMirror")).not.toBeInTheDocument(); + }); + + it("renders the validation message", () => { + render( + () + .withValue(richTextDefaultValue) + .withValidation("Required") + .build()} + /> + ); + + expect(screen.getByText("Required")).toBeInTheDocument(); + }); + }); + + describe("read-only styles", () => { + // Each read-only style maps to a root class; only "text" keeps the toolbar, and the + // editor content must not be editable. + function renderReadOnly(readOnlyStyle: RichTextContainerProps["readOnlyStyle"]): RenderResult { + return render( + () + .withValue(richTextDefaultValue) + .isReadOnly() + .build()} + /> + ); + } + + it.each([ + ["bordered", "form-control", false], + ["text", "form-control", true], + ["readPanel", "form-control-static", false] + ] as const)("applies the %s style", (readOnlyStyle, controlClass, hasToolbar) => { + const { container } = renderReadOnly(readOnlyStyle); + const root = container.querySelector(".widget-rich-text") as HTMLElement; + + expect(root).toHaveClass(controlClass, `widget-rich-text-readonly-${readOnlyStyle}`); + expect(container.querySelector(".tiptap-toolbar") !== null).toBe(hasToolbar); + expect(getEditorDom(container)).toHaveAttribute("contenteditable", "false"); + }); + + it.todo( + "becomes non-editable when the attribute turns read-only after mount — blocked by BEH-02: Editor never calls setEditable after creation, contenteditable stays true" + ); + }); + + describe("toolbar location", () => { + // The root class drives the toolbar placement in SCSS; "hide" must not render it at all. + it.each([ + ["auto", true], + ["top", true], + ["bottom", true], + ["hide", false] + ] as const)("renders toolbar location %s", (toolbarLocation, hasToolbar) => { + const { container } = render( + + ); + + expect(container.querySelector(".widget-rich-text")).toHaveClass(`toolbar-${toolbarLocation}`); + expect(container.querySelector(".tiptap-toolbar") !== null).toBe(hasToolbar); + }); + }); +}); From 2a9b4e1c6c27c38747266c1f8809f28922b210c9 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 6 Oct 2026 12:04:23 +0200 Subject: [PATCH 2/7] test(rich-text-web): cover class style format and embed allowlist Co-Authored-By: Claude Opus 5.5 --- .../extensions/__tests__/GenericEmbed.spec.ts | 91 +++++++ .../extensions/__tests__/classFormat.spec.ts | 234 ++++++++++++++++++ .../utils/__tests__/embedCodeParser.spec.ts | 117 +++++++++ .../utils/__tests__/videoUrlPattern.spec.ts | 98 ++++++++ 4 files changed, 540 insertions(+) create mode 100644 packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/GenericEmbed.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/utils/__tests__/embedCodeParser.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/GenericEmbed.spec.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/GenericEmbed.spec.ts new file mode 100644 index 0000000000..cb53b3c476 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/GenericEmbed.spec.ts @@ -0,0 +1,91 @@ +import { Editor, JSONContent } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { GenericEmbed } from "../GenericEmbed"; + +function makeEditor(): Editor { + const element = document.createElement("div"); + document.body.appendChild(element); + return new Editor({ element, extensions: [StarterKit, GenericEmbed] }); +} + +function embedNodes(editor: Editor): JSONContent[] { + return (editor.getJSON().content ?? []).filter(node => node.type === "genericEmbed"); +} + +const wrap = (src: string): string => `
`; + +let editor: Editor; +afterEach(() => editor?.destroy()); + +// GenericEmbed only parses its own wrapper (div[data-generic-embed]); bare iframes are not +// turned into embed nodes. Inside the wrapper, the iframe src is vetted by parseEmbedCode: +// a disallowed src is dropped on parse, and renderHTML refuses to emit an iframe for it. +describe("GenericEmbed — parse vetting", () => { + it.each([ + { name: "disallowed iframe", html: '' }, + { name: "allowlisted iframe without wrapper", html: '' } + ])("bare $name yields no embed node", ({ html }) => { + editor = makeEditor(); + editor.commands.setContent(html); + + expect(embedNodes(editor)).toHaveLength(0); + expect(editor.getHTML()).not.toContain(" { + editor = makeEditor(); + editor.commands.setContent(wrap("https://www.youtube.com/embed/x")); + + const nodes = embedNodes(editor); + expect(nodes).toHaveLength(1); + expect(nodes[0].attrs).toMatchObject({ src: "https://www.youtube.com/embed/x", width: 640, height: 480 }); + }); + + it("renders the allowlisted iframe with sandbox and without allow-same-origin", () => { + editor = makeEditor(); + editor.commands.setContent(wrap("https://www.youtube.com/embed/x")); + + const html = editor.getHTML(); + expect(html).toContain('src="https://www.youtube.com/embed/x"'); + expect(html).toMatch(/sandbox="[^"]*allow-scripts/); + expect(html).not.toContain("allow-same-origin"); + expect(html).toContain('referrerpolicy="strict-origin-when-cross-origin"'); + }); + + it.each([ + "https://evil.com/x", + "https://youtube.com.evil.io/embed/x", + "javascript:alert(1)", + "DATA:text/html,x", + "ftp://youtube.com/x" + ])("wrapped iframe with src %p keeps no src and renders no iframe", src => { + editor = makeEditor(); + editor.commands.setContent(wrap(src)); + + const nodes = embedNodes(editor); + expect(nodes).toHaveLength(1); + expect(nodes[0].attrs?.src).toBeNull(); + expect(editor.getHTML()).not.toContain(" { + it("does not render an iframe for a disallowed src inserted via command", () => { + editor = makeEditor(); + editor.commands.setGenericEmbed({ src: "https://evil.com/x" }); + + expect(embedNodes(editor)).toHaveLength(1); + expect(editor.getHTML()).not.toContain(" { + editor = makeEditor(); + editor.commands.setGenericEmbed({ src: "https://player.vimeo.com/video/1", width: 400, height: 300 }); + + const html = editor.getHTML(); + expect(html).toContain('src="https://player.vimeo.com/video/1"'); + expect(html).toContain('width="400"'); + expect(html).toContain('height="300"'); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts new file mode 100644 index 0000000000..0880ab7f25 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts @@ -0,0 +1,234 @@ +import { Editor } from "@tiptap/core"; +import { TextStyle } from "@tiptap/extension-text-style"; +import { StarterKit } from "@tiptap/starter-kit"; +import { FontFamilyClass } from "../FontFamilyClass"; +import { FontSize } from "../FontSize"; +import { TextAlign } from "../TextAlignClass"; +import { TextColorClass } from "../TextColorClass"; +import { TextHighlightClass } from "../TextHighlightClass"; + +type StyleFormat = "inline" | "class"; + +function makeEditor(styleDataFormat: StyleFormat): Editor { + const element = document.createElement("div"); + document.body.appendChild(element); + return new Editor({ + element, + extensions: [ + StarterKit, + TextStyle, + TextColorClass.configure({ types: ["textStyle"], styleDataFormat }), + TextHighlightClass.configure({ multicolor: true, styleDataFormat }), + FontFamilyClass.configure({ types: ["textStyle"], styleDataFormat }), + FontSize.configure({ types: ["textStyle"], styleDataFormat }), + TextAlign.configure({ + types: ["heading", "paragraph"], + alignments: ["left", "center", "right", "justify"], + styleDataFormat + }) + ] + }); +} + +/** Selects all text in the document so mark commands apply to it. */ +function selectAll(editor: Editor): void { + editor.commands.setTextSelection({ from: 1, to: editor.state.doc.content.size - 1 }); +} + +let editor: Editor; +afterEach(() => editor?.destroy()); + +const TEXT_STYLE_BUG = + "bug: TextStyle mark only parses with a `style` attribute, so class-mode spans carrying only data-*/class are dropped on load"; + +// Class mode: every text-mark extension serializes its value into a data-* attribute +// plus a marker class, and never into an inline `style` for that property. Loading the +// class/data form back must restore the same value so getHTML() is a fixed point. +describe("text-mark extensions — class format", () => { + // Highlight is its own mark and TextAlign a node attribute, so both parse the class form. + it.each([ + { + name: "TextHighlightClass", + html: '

x

', + expected: ['data-text-highlight="#ffff00"', "has-text-highlight"] + }, + { + name: "TextAlign (data attribute)", + html: '

x

', + expected: ['data-text-align="center"', 'class="text-align-center"'] + }, + { + name: "TextAlign (class only)", + html: '

x

', + expected: ['data-text-align="justify"', 'class="text-align-justify"'] + } + ])("$name: loads the class/data form and emits it back without inline style", ({ html, expected }) => { + editor = makeEditor("class"); + editor.commands.setContent(html); + + const out = editor.getHTML(); + for (const fragment of expected) { + expect(out).toContain(fragment); + } + expect(out).not.toContain("style="); + }); + + // textStyle-based marks (color, font family, font size) cannot be loaded from the class form. + it.todo(`TextColorClass: loads — ${TEXT_STYLE_BUG}`); + it.todo(`FontFamilyClass: loads — ${TEXT_STYLE_BUG}`); + it.todo(`FontSize: loads — ${TEXT_STYLE_BUG}`); + + it.each([ + { + name: "setTextColor", + apply: (e: Editor) => e.commands.setTextColor("#00ff00"), + expected: ['data-text-color="#00ff00"', "has-text-color"] + }, + { + name: "setTextHighlight", + apply: (e: Editor) => e.commands.setTextHighlight("#00ff00"), + expected: ['data-text-highlight="#00ff00"', "has-text-highlight"] + }, + { + name: "setFontFamily", + apply: (e: Editor) => e.commands.setFontFamily("Arial"), + expected: ['data-font-family="Arial"', "has-font-family", 'data-font-value="arial"'] + }, + { + name: "setFontSize", + apply: (e: Editor) => e.commands.setFontSize("18px"), + expected: ['data-font-size="18"', "has-font-size"] + }, + { + name: "setTextAlign", + apply: (e: Editor) => e.commands.setTextAlign("right"), + expected: ['data-text-align="right"', "text-align-right"] + } + ])("$name command writes the data-* form without inline style", ({ apply, expected }) => { + editor = makeEditor("class"); + editor.commands.setContent("

x

"); + selectAll(editor); + + apply(editor); + + const out = editor.getHTML(); + for (const fragment of expected) { + expect(out).toContain(fragment); + } + expect(out).not.toContain("style="); + }); + + it.each([ + { name: "setTextHighlight", apply: (e: Editor) => e.commands.setTextHighlight("#00ff00") }, + { name: "setTextAlign", apply: (e: Editor) => e.commands.setTextAlign("right") } + ])("$name: setContent(getHTML()) round-trip is stable", ({ apply }) => { + editor = makeEditor("class"); + editor.commands.setContent("

x

"); + selectAll(editor); + apply(editor); + const first = editor.getHTML(); + + editor.commands.setContent(first); + + expect(editor.getHTML()).toBe(first); + }); + + it.todo( + `setTextColor/setFontFamily/setFontSize: setContent(getHTML()) round-trip keeps the mark — ${TEXT_STYLE_BUG}` + ); + + it("FontSize keeps only the leading number, dropping the unit", () => { + editor = makeEditor("class"); + editor.commands.setContent("

x

"); + selectAll(editor); + + editor.commands.setFontSize("18.5px"); + + expect(editor.getHTML()).toContain('data-font-size="18.5"'); + }); + + it("FontFamilyClass derives a kebab-case data-font-value from the family", () => { + editor = makeEditor("class"); + editor.commands.setContent("

x

"); + selectAll(editor); + + editor.commands.setFontFamily("'Times New Roman', serif"); + + expect(editor.getHTML()).toContain('data-font-value="times-new-roman"'); + }); +}); + +// Values are untrusted (pasted HTML, stored data). renderHTML must drop values that fail +// the extension's safety guard instead of re-emitting them. +describe("text-mark extensions — class format rejects unsafe values", () => { + it.each([ + { name: "setTextColor", apply: (e: Editor) => e.commands.setTextColor("red;background:url(x)") }, + { name: "setTextHighlight", apply: (e: Editor) => e.commands.setTextHighlight("#fff;}">', + error: "Data URLs are not allowed" + }, + { + name: "ftp:// src", + html: '', + error: "Only HTTP/HTTPS URLs are allowed" + }, + { name: "relative src", html: '', error: "Invalid URL in src attribute" }, + { name: "malformed URL", html: '', error: "Invalid URL in src attribute" } + ])("$name", ({ html, error }) => { + const result = parseEmbedCode(html); + + expect(result.valid).toBe(false); + expect(result.error).toBe(error); + expect(result.src).toBeUndefined(); + }); + + it.each([ + { name: "disallowed domain", src: "https://evil.com/x", host: "evil.com" }, + { + name: "allowlisted name as subdomain prefix", + src: "https://youtube.com.evil.io/embed/x", + host: "youtube.com.evil.io" + }, + { + name: "allowlisted name without dot boundary", + src: "https://notyoutube.com/embed/x", + host: "notyoutube.com" + }, + { name: "allowlisted name in userinfo", src: "https://youtube.com@evil.com/embed/x", host: "evil.com" }, + { name: "allowlisted name in path", src: "https://evil.com/www.youtube.com/embed/x", host: "evil.com" } + ])("rejects $name", ({ src, host }) => { + const result = parseEmbedCode(``); + + expect(result.valid).toBe(false); + expect(result.error).toContain(`Domain "${host}" is not in the allowed list`); + }); +}); + +describe("parseEmbedCode — accepts allowlisted embeds", () => { + it("returns the src and applies default attributes when none are given", () => { + const result = parseEmbedCode(''); + + expect(result).toEqual({ + valid: true, + src: "https://player.vimeo.com/video/123", + width: "640", + height: "480", + title: null, + frameborder: "0", + allow: null, + allowfullscreen: false, + domain: "player.vimeo.com" + }); + }); + + it("keeps explicit attributes from the iframe", () => { + const result = parseEmbedCode( + '' + ); + + expect(result).toMatchObject({ + valid: true, + src: "https://www.youtube.com/embed/abc", + width: "560", + height: "315", + title: "Clip", + frameborder: "1", + allow: "autoplay", + allowfullscreen: true, + domain: "www.youtube.com" + }); + }); + + it.each([ + "https://youtube.com/embed/x", + "https://codepen.io/pen/x", + "https://m.youtube.com/embed/x", + "http://player.dailymotion.com/embed/video/x" + ])("accepts %s (exact or subdomain of an allowlisted host)", src => { + expect(parseEmbedCode(``).valid).toBe(true); + }); + + it("only inspects the first iframe in the snippet", () => { + const result = parseEmbedCode( + '' + ); + + expect(result.valid).toBe(true); + expect(result.src).toBe("https://www.youtube.com/embed/a"); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts new file mode 100644 index 0000000000..fefa3ee846 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts @@ -0,0 +1,98 @@ +import { getPatternMatch, matchPattern } from "../videoUrlPattern"; + +// matchPattern turns a user-entered video page URL into the provider's embed URL plus the +// default iframe size; unknown URLs yield null. The protocol of the input is preserved +// (http/https), and a bare "www." input defaults to https. +describe("matchPattern", () => { + it.each([ + { + name: "YouTube watch URL", + input: "https://www.youtube.com/watch?v=abc123", + url: "https://www.youtube.com/embed/abc123", + w: 560, + h: 314 + }, + { + name: "YouTube watch URL with extra params", + input: "https://www.youtube.com/watch?v=abc123&t=42", + url: "https://www.youtube.com/embed/abc123?t=42", + w: 560, + h: 314 + }, + { + name: "YouTube short URL", + input: "https://youtu.be/abc123", + url: "https://www.youtube.com/embed/abc123", + w: 560, + h: 314 + }, + { + name: "YouTube embed URL", + input: "https://www.youtube.com/embed/abc123", + url: "https://www.youtube.com/embed/abc123", + w: 560, + h: 314 + }, + { + name: "Vimeo URL", + input: "https://vimeo.com/123456", + url: "https://player.vimeo.com/video/123456?title=0&byline=0&portrait=0&color=8dc7dc", + w: 425, + h: 350 + }, + { + name: "Vimeo URL with privacy hash", + input: "https://vimeo.com/123456?h=abc", + url: "https://player.vimeo.com/video/123456?h=abc&title=0&byline=0&portrait=0&color=8dc7dc", + w: 425, + h: 350 + }, + { + name: "Dailymotion URL", + input: "https://www.dailymotion.com/video/x7abc", + url: "https://www.dailymotion.com/embed/video/x7abc", + w: 480, + h: 270 + }, + { + name: "http input keeps http", + input: "http://youtu.be/abc123", + url: "http://www.youtube.com/embed/abc123", + w: 560, + h: 314 + }, + { + name: "www. input defaults to https", + input: "www.youtube.com/watch?v=abc123", + url: "https://www.youtube.com/embed/abc123", + w: 560, + h: 314 + } + ])("$name", ({ input, url, w, h }) => { + expect(matchPattern(input)).toMatchObject({ type: "iframe", url, w, h }); + }); + + it.each(["https://example.com/video/1", "https://evil.com/watch?x=1", "not a url", ""])( + "returns null for unknown URL %p", + input => { + expect(matchPattern(input)).toBeNull(); + expect(getPatternMatch(input)).toBeNull(); + } + ); + + it("always targets the provider host, even when the provider name appears in another host's path", () => { + expect(matchPattern("https://evil.com/youtu.be/abc")?.url).toBe("https://www.youtube.com/embed/abc"); + }); + + it.todo( + "Google Maps URL builds a valid embed URL — bug: src/utils/videoUrlPattern.ts:66 uses msid=$2 (path group, not msid group $3) and has a stray trailing double quote" + ); +}); + +describe("getPatternMatch", () => { + it("returns the raw pattern with its template URL (not the resolved one)", () => { + const pattern = getPatternMatch("https://youtu.be/abc123"); + + expect(pattern).toMatchObject({ type: "iframe", w: 560, h: 314, url: "www.youtube.com/embed/$1" }); + }); +}); From 0e566f3f1eb9286eb0025cccf078459ed31ace79 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 6 Oct 2026 12:04:25 +0200 Subject: [PATCH 3/7] test(rich-text-web): cover keyboard shortcuts and listener cleanup Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/KeyboardNavigation.spec.tsx | 351 ++++++++++++++++++ .../src/__tests__/cleanup.spec.tsx | 121 ++++++ .../hooks/__tests__/useDropdown.spec.tsx | 100 +++++ 3 files changed, 572 insertions(+) create mode 100644 packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx create mode 100644 packages/pluggableWidgets/rich-text-web/src/__tests__/cleanup.spec.tsx create mode 100644 packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/__tests__/useDropdown.spec.tsx diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx new file mode 100644 index 0000000000..8fc696c914 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx @@ -0,0 +1,351 @@ +import "@testing-library/jest-dom"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { Editor as CoreEditor } from "@tiptap/core"; +import { Editor } from "@tiptap/react"; +import { StarterKit } from "@tiptap/starter-kit"; +import { ReactElement, ReactNode } from "react"; +import { EditableValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { RichTextContainerProps, StatusBarContentEnum } from "../../typings/RichTextProps"; +import { EditorContextProvider } from "../components/EditorContext"; +import { ConfigurationDropdown, ConfigurationSection } from "../components/toolbars/components/ConfigurationDropdown"; +import { ToolbarButton } from "../components/toolbars/components/ToolbarButton"; +import { ToolbarButtonConfig } from "../components/toolbars/ToolbarConfig"; +import RichText from "../RichText"; +import { TranslationProvider } from "../utils/i18n"; + +function buildProps(overrides: Partial = {}): RichTextContainerProps { + return { + name: "RichText", + id: "RichText1", + stringAttribute: new EditableValueBuilder().withValue("

text

").build(), + preset: "full", + toolbarLocation: "top", + widthUnit: "percentage", + width: 100, + heightUnit: "percentageOfWidth", + height: 75, + toolbarConfig: "basic", + history: true, + fontStyle: true, + fontScript: true, + fontColor: true, + code: true, + indent: true, + embed: true, + align: true, + list: true, + remove: true, + header: true, + view: true, + tableBetter: false, + helpButton: true, + advancedConfig: [], + readOnlyStyle: "text", + tabIndex: 0, + onChangeType: "onLeave", + enableStatusBar: true, + dialogStyle: "inline", + statusBarContent: "wordCount" as StatusBarContentEnum, + spellCheck: true, + minHeightUnit: "none", + maxHeightUnit: "none", + maxHeight: 0, + minHeight: 75, + OverflowY: "auto", + customFonts: [], + enableDefaultUpload: true, + linkValidation: true, + styleDataFormat: "inline", + ...overrides + }; +} + +type EditorDom = HTMLElement & { editor: CoreEditor }; + +function renderWidget(overrides: Partial = {}): { + container: HTMLElement; + dom: EditorDom; + editor: CoreEditor; +} { + const { container } = render(); + const dom = container.querySelector(".ProseMirror") as EditorDom; + return { container, dom, editor: dom.editor }; +} + +// Renders a toolbar component against a standalone editor, with the contexts it reads. +function renderWithEditor(editor: Editor, children: ReactNode): ReturnType { + const Wrapper = (): ReactElement => ( + + + {children} + + + ); + return render(); +} + +function createEditor(): Editor { + const element = document.createElement("div"); + document.body.appendChild(element); + return new Editor({ element, extensions: [StarterKit], content: "

text

" }); +} + +// Contract (KeyboardNavigation extension): Alt+F10 inside the editor moves focus to the first +// enabled toolbar button (or the wrapper when the toolbar is hidden); Alt+F11 moves focus to +// the status bar. +describe("KeyboardNavigation shortcuts", () => { + it("Alt+F10 focuses the first enabled toolbar button", () => { + const { container, dom } = renderWidget(); + const toolbar = container.querySelector(".tiptap-toolbar") as HTMLElement; + const firstEnabled = toolbar.querySelector("button:not([disabled])"); + expect(firstEnabled).not.toBeNull(); + + fireEvent.keyDown(dom, { key: "F10", altKey: true }); + + expect(document.activeElement).toBe(firstEnabled); + }); + + it("Alt+F10 focuses the wrapper when the toolbar is hidden", () => { + const { container, dom } = renderWidget({ toolbarLocation: "hide" }); + expect(container.querySelector(".tiptap-toolbar")).toBeNull(); + const wrapper = container.querySelector(".tiptap-wrapper") as HTMLElement; + + fireEvent.keyDown(dom, { key: "F10", altKey: true }); + + expect(wrapper).toHaveAttribute("tabindex", "0"); + expect(document.activeElement).toBe(wrapper); + }); + + it("Alt+F11 focuses the status bar", () => { + const { container, dom } = renderWidget(); + const statusBar = container.querySelector(".rich-text-status-bar") as HTMLElement; + expect(statusBar).not.toBeNull(); + + fireEvent.keyDown(dom, { key: "F11", altKey: true }); + + expect(document.activeElement).toBe(statusBar); + }); + + it("F10 without Alt does not move focus to the toolbar", () => { + const { container, dom } = renderWidget(); + const toolbar = container.querySelector(".tiptap-toolbar") as HTMLElement; + + fireEvent.keyDown(dom, { key: "F10" }); + + expect(toolbar.contains(document.activeElement)).toBe(false); + }); + + it.todo( + "Escape pressed on a focused toolbar button returns focus to the editor — blocked by BEH-12: handleKeyDown in src/extensions/KeyboardNavigation.ts:89-103 only receives keys dispatched inside the editor, but only acts when focus is outside it" + ); +}); + +// Contract (Fullscreen extension): Escape exits fullscreen only when it is active. +describe("Fullscreen Escape", () => { + it("Escape removes the fullscreen class after toggleFullscreen", () => { + const { container, dom, editor } = renderWidget(); + const widget = container.querySelector(".widget-rich-text") as HTMLElement; + + act(() => { + editor.commands.toggleFullscreen(); + }); + expect(widget).toHaveClass("fullscreen"); + + fireEvent.keyDown(dom, { key: "Escape" }); + + expect(widget).not.toHaveClass("fullscreen"); + }); + + it("Escape is a no-op when not in fullscreen", () => { + const { container, dom } = renderWidget(); + const widget = container.querySelector(".widget-rich-text") as HTMLElement; + + fireEvent.keyDown(dom, { key: "Escape" }); + + expect(widget).not.toHaveClass("fullscreen"); + }); +}); + +// Contract (ToolbarButton): Tab (without Shift) on the last enabled toolbar button returns +// focus to the editor instead of leaving the widget; other buttons keep native Tab behaviour. +describe("ToolbarButton Tab handling", () => { + const bold: ToolbarButtonConfig = { + name: "bold", + title: "toolbar.bold", + icon: "Bold", + action: "toggle", + command: "toggleBold" + }; + const italic: ToolbarButtonConfig = { + name: "italic", + title: "toolbar.italic", + icon: "Italic", + action: "toggle", + command: "toggleItalic" + }; + + let editor: Editor; + let focusSpy: jest.SpyInstance; + + beforeEach(() => { + editor = createEditor(); + focusSpy = jest.spyOn(editor.view, "focus"); + renderWithEditor( + editor, +
+ + +
+ ); + }); + + afterEach(() => { + editor.destroy(); + document.body.innerHTML = ""; + }); + + it("Tab on the last button focuses the editor and prevents default", () => { + const last = screen.getByTitle("Italic"); + + const notPrevented = fireEvent.keyDown(last, { key: "Tab" }); + + expect(notPrevented).toBe(false); + expect(focusSpy).toHaveBeenCalledTimes(1); + }); + + it("Tab on a non-last button keeps native behaviour", () => { + const notPrevented = fireEvent.keyDown(screen.getByTitle("Bold"), { key: "Tab" }); + + expect(notPrevented).toBe(true); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + it("Shift+Tab on the last button keeps native behaviour", () => { + const notPrevented = fireEvent.keyDown(screen.getByTitle("Italic"), { key: "Tab", shiftKey: true }); + + expect(notPrevented).toBe(true); + expect(focusSpy).not.toHaveBeenCalled(); + }); +}); + +// Contract (ToolbarSplitButton): ArrowRight moves from main to dropdown part, ArrowLeft moves +// back, ArrowDown opens the style menu. +describe("ToolbarSplitButton arrow keys", () => { + function getParts(container: HTMLElement): { main: HTMLButtonElement; dropdown: HTMLButtonElement } { + return { + main: container.querySelector(".split-button-main") as HTMLButtonElement, + dropdown: container.querySelector(".split-button-dropdown") as HTMLButtonElement + }; + } + + it("ArrowRight on the main part focuses the dropdown part", () => { + const { container } = renderWidget(); + const { main, dropdown } = getParts(container); + main.focus(); + + fireEvent.keyDown(main, { key: "ArrowRight" }); + + expect(document.activeElement).toBe(dropdown); + }); + + it("ArrowLeft on the dropdown part focuses the main part", () => { + const { container } = renderWidget(); + const { main, dropdown } = getParts(container); + dropdown.focus(); + + fireEvent.keyDown(dropdown, { key: "ArrowLeft" }); + + expect(document.activeElement).toBe(main); + }); + + it("ArrowLeft on the main part does not move focus", () => { + const { container } = renderWidget(); + const { main } = getParts(container); + main.focus(); + + fireEvent.keyDown(main, { key: "ArrowLeft" }); + + expect(document.activeElement).toBe(main); + }); + + it("ArrowDown opens the style menu", () => { + const { container } = renderWidget(); + const { main, dropdown } = getParts(container); + expect(dropdown).toHaveAttribute("aria-expanded", "false"); + + fireEvent.keyDown(main, { key: "ArrowDown" }); + + expect(dropdown).toHaveAttribute("aria-expanded", "true"); + expect(container.querySelector(".split-button [role='menu']")).toBeInTheDocument(); + }); +}); + +// Contract (ConfigurationDropdown buffered inputs): Enter commits the draft value and blurs; +// Escape discards the draft (no commit) and blurs. +describe("ConfigurationDropdown Enter/Escape", () => { + let editor: Editor; + let onChange: jest.Mock; + + beforeEach(() => { + editor = createEditor(); + onChange = jest.fn(); + const section: ConfigurationSection = { + id: "width", + label: "Width", + type: "textInput", + getCurrentValue: () => "10", + onChange + }; + const config: ToolbarButtonConfig = { + name: "tableConfiguration", + title: "toolbar.tableConfiguration", + icon: "Table-configuration", + action: "configurationDropdown", + configurationSections: [section] + }; + renderWithEditor(editor, ); + fireEvent.click(screen.getByRole("button")); + }); + + afterEach(() => { + editor.destroy(); + document.body.innerHTML = ""; + }); + + it("Enter commits the draft value and blurs the input", () => { + const input = document.querySelector(".configuration-input") as HTMLInputElement; + input.focus(); + fireEvent.change(input, { target: { value: "42" } }); + + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onChange).toHaveBeenCalledWith("42"); + expect(document.activeElement).not.toBe(input); + }); + + it("typing alone does not commit to the editor", () => { + const input = document.querySelector(".configuration-input") as HTMLInputElement; + fireEvent.change(input, { target: { value: "42" } }); + + expect(onChange).not.toHaveBeenCalled(); + expect(input.value).toBe("42"); + }); + + it("Escape blurs the input", () => { + const input = document.querySelector(".configuration-input") as HTMLInputElement; + input.focus(); + fireEvent.change(input, { target: { value: "42" } }); + + fireEvent.keyDown(input, { key: "Escape" }); + + expect(document.activeElement).not.toBe(input); + }); + + it.todo( + "Escape discards the draft without committing it — bug: ConfigurationDropdown.tsx:63-66 calls blur() before the clearDraft state update applies, so onBlur (line 151) commits the draft value" + ); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/cleanup.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/cleanup.spec.tsx new file mode 100644 index 0000000000..5829053223 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/cleanup.spec.tsx @@ -0,0 +1,121 @@ +import "@testing-library/jest-dom"; +import { act, render, screen } from "@testing-library/react"; +import { EditableValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { RichTextContainerProps, StatusBarContentEnum } from "../../typings/RichTextProps"; +import { IMAGE_DROP_ERROR_EVENT, IMAGE_REQUEST_EVENT } from "../extensions/ImagePasteDrop"; +import RichText from "../RichText"; + +function buildProps(): RichTextContainerProps { + return { + name: "RichText", + id: "RichText1", + stringAttribute: new EditableValueBuilder().withValue("

text

").build(), + preset: "full", + toolbarLocation: "top", + widthUnit: "percentage", + width: 100, + heightUnit: "percentageOfWidth", + height: 75, + toolbarConfig: "basic", + history: true, + fontStyle: true, + fontScript: true, + fontColor: true, + code: true, + indent: true, + embed: true, + align: true, + list: true, + remove: true, + header: true, + view: true, + tableBetter: false, + helpButton: true, + advancedConfig: [], + readOnlyStyle: "text", + tabIndex: 0, + onChangeType: "onLeave", + enableStatusBar: true, + dialogStyle: "inline", + statusBarContent: "wordCount" as StatusBarContentEnum, + spellCheck: true, + minHeightUnit: "none", + maxHeightUnit: "none", + maxHeight: 0, + minHeight: 75, + OverflowY: "auto", + customFonts: [], + enableDefaultUpload: true, + linkValidation: true, + styleDataFormat: "inline" + }; +} + +function getProseMirror(container: HTMLElement): HTMLElement { + return container.querySelector(".ProseMirror") as HTMLElement; +} + +// Contract: the custom DOM-event listeners that Editor (drop errors) and Toolbar (image +// requests) attach to the ProseMirror element are detached with the same handler on unmount. +describe("RichText listener cleanup on unmount", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it.each([ + ["Editor", IMAGE_DROP_ERROR_EVENT], + ["Toolbar", IMAGE_REQUEST_EVENT] + ])("%s removes its %s listener from the editor DOM", (_owner, eventName) => { + const addSpy = jest.spyOn(EventTarget.prototype, "addEventListener"); + const removeSpy = jest.spyOn(EventTarget.prototype, "removeEventListener"); + + const { container, unmount } = render(); + const dom = getProseMirror(container); + + const added = addSpy.mock.calls + .map((call, i) => ({ type: call[0], handler: call[1], target: addSpy.mock.contexts[i] })) + .filter(({ type, target }) => type === eventName && target === dom); + expect(added.length).toBeGreaterThan(0); + + unmount(); + + const removed = removeSpy.mock.calls + .map((call, i) => ({ type: call[0], handler: call[1], target: removeSpy.mock.contexts[i] })) + .filter(({ type, target }) => type === eventName && target === dom); + + for (const { handler } of added) { + expect(removed.map(r => r.handler)).toContain(handler); + } + }); +}); + +// Contract: a rejected image drop/paste (reported by ImagePasteDrop as a DOM event on the +// editor element) is shown as a status message that disappears after 5 seconds. +describe("RichText drop-error status", () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it("shows a status message on drop error and hides it after 5000ms", () => { + const { container } = render(); + const dom = getProseMirror(container); + + jest.useFakeTimers(); + act(() => { + dom.dispatchEvent(new CustomEvent(IMAGE_DROP_ERROR_EVENT, { detail: { key: "image.errorNotImage" } })); + }); + + const status = screen.getByRole("status"); + expect(status).toHaveTextContent("Only image files are allowed"); + + act(() => { + jest.advanceTimersByTime(4999); + }); + expect(screen.getByRole("status")).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/__tests__/useDropdown.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/__tests__/useDropdown.spec.tsx new file mode 100644 index 0000000000..1ecef8af3e --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/hooks/__tests__/useDropdown.spec.tsx @@ -0,0 +1,100 @@ +import { act, fireEvent, renderHook, RenderHookResult } from "@testing-library/react"; +import { useDropdown, UseDropdownOptions, UseDropdownReturn } from "../useDropdown"; + +// Contract: while open, a mousedown outside both the reference and the floating element +// closes the dropdown. The document listener exists only while open and is removed on +// close/unmount. +describe("useDropdown click-outside handling", () => { + let reference: HTMLButtonElement; + let floating: HTMLDivElement; + let outside: HTMLDivElement; + + beforeEach(() => { + reference = document.createElement("button"); + floating = document.createElement("div"); + floating.appendChild(document.createElement("span")); + outside = document.createElement("div"); + document.body.append(reference, floating, outside); + }); + + afterEach(() => { + document.body.innerHTML = ""; + jest.restoreAllMocks(); + }); + + function setup(initial: UseDropdownOptions): RenderHookResult { + const hook = renderHook((props: UseDropdownOptions) => useDropdown(props), { initialProps: initial }); + act(() => { + hook.result.current.refs.setReference(reference); + hook.result.current.refs.setFloating(floating); + }); + return hook; + } + + it("calls onClose on mousedown outside both reference and floating element", () => { + const onClose = jest.fn(); + setup({ isOpen: true, onClose }); + + fireEvent.mouseDown(outside); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not call onClose on mousedown inside the floating element", () => { + const onClose = jest.fn(); + setup({ isOpen: true, onClose }); + + fireEvent.mouseDown(floating.firstChild as HTMLElement); + fireEvent.mouseDown(floating); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("does not call onClose on mousedown on the reference element", () => { + const onClose = jest.fn(); + setup({ isOpen: true, onClose }); + + fireEvent.mouseDown(reference); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("registers no mousedown listener while closed", () => { + const addSpy = jest.spyOn(document, "addEventListener"); + const onClose = jest.fn(); + setup({ isOpen: false, onClose }); + + fireEvent.mouseDown(outside); + + expect(addSpy).not.toHaveBeenCalledWith("mousedown", expect.any(Function)); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("removes the mousedown listener when closed after being open", () => { + const removeSpy = jest.spyOn(document, "removeEventListener"); + const onClose = jest.fn(); + const hook = setup({ isOpen: true, onClose }); + + hook.rerender({ isOpen: false, onClose }); + fireEvent.mouseDown(outside); + + expect(removeSpy).toHaveBeenCalledWith("mousedown", expect.any(Function)); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("removes the same mousedown listener on unmount", () => { + const addSpy = jest.spyOn(document, "addEventListener"); + const removeSpy = jest.spyOn(document, "removeEventListener"); + const onClose = jest.fn(); + const hook = setup({ isOpen: true, onClose }); + + const added = addSpy.mock.calls.filter(([type]) => type === "mousedown").map(([, fn]) => fn); + expect(added).toHaveLength(1); + + hook.unmount(); + + expect(removeSpy).toHaveBeenCalledWith("mousedown", added[0]); + fireEvent.mouseDown(outside); + expect(onClose).not.toHaveBeenCalled(); + }); +}); From 19fd2d90d606ad5d56a8feca18ab77af0a2c6fce Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 6 Oct 2026 12:04:27 +0200 Subject: [PATCH 4/7] test(rich-text-web): test link dialog and real link bubble menu Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/LinkBubbleMenu.spec.ts | 90 --------- .../src/__tests__/LinkBubbleMenu.spec.tsx | 167 ++++++++++++++++ .../components/__tests__/LinkDialog.spec.tsx | 183 ++++++++++++++++++ 3 files changed, 350 insertions(+), 90 deletions(-) delete mode 100644 packages/pluggableWidgets/rich-text-web/src/__tests__/LinkBubbleMenu.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/__tests__/LinkBubbleMenu.spec.tsx create mode 100644 packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/LinkDialog.spec.tsx diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/LinkBubbleMenu.spec.ts b/packages/pluggableWidgets/rich-text-web/src/__tests__/LinkBubbleMenu.spec.ts deleted file mode 100644 index 98157e00af..0000000000 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/LinkBubbleMenu.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { StarterKit } from "@tiptap/starter-kit"; - -function makeEditor(content: string): Editor { - const element = document.createElement("div"); - document.body.appendChild(element); - return new Editor({ - element, - extensions: [StarterKit.configure({ link: { openOnClick: false } })], - content - }); -} - -/** Place a collapsed caret inside the first occurrence of the given text. */ -function placeCaretInText(editor: Editor, text: string): void { - let target = -1; - editor.state.doc.descendants((node, pos) => { - if (target === -1 && node.isText && node.text?.includes(text)) { - target = pos + (node.text.indexOf(text) + 1); - return false; - } - return true; - }); - editor.commands.setTextSelection(target); -} - -// Mirrors the predicate used by LinkBubbleMenu's shouldShow. -function shouldShow(editor: Editor, isEditing: boolean): boolean { - return editor.isEditable && editor.isActive("link") && !isEditing; -} - -describe("LinkBubbleMenu behavior", () => { - describe("shouldShow", () => { - it("is true when the caret is inside a link and editable", () => { - const editor = makeEditor('

linked plain

'); - placeCaretInText(editor, "linked"); - - expect(shouldShow(editor, false)).toBe(true); - }); - - it("is false when the caret is not inside a link", () => { - const editor = makeEditor('

linked plain

'); - placeCaretInText(editor, "plain"); - - expect(shouldShow(editor, false)).toBe(false); - }); - - it("is false while the dialog is open (isEditing)", () => { - const editor = makeEditor('

linked

'); - placeCaretInText(editor, "linked"); - - expect(shouldShow(editor, true)).toBe(false); - }); - - it("is false when the editor is not editable", () => { - const editor = makeEditor('

linked

'); - placeCaretInText(editor, "linked"); - editor.setEditable(false); - - expect(shouldShow(editor, false)).toBe(false); - }); - }); - - describe("remove", () => { - it("strips the whole link from a bare caret", () => { - const editor = makeEditor('

linked

'); - placeCaretInText(editor, "linked"); - - editor.chain().focus().extendMarkRange("link").unsetLink().run(); - - expect(editor.isActive("link")).toBe(false); - expect(editor.getHTML()).not.toContain(" { - it("selects the full link range from a bare caret so the dialog can prefill", () => { - const editor = makeEditor('

linked

'); - placeCaretInText(editor, "linked"); - expect(editor.state.selection.empty).toBe(true); - - editor.chain().focus().extendMarkRange("link").run(); - - const { from, to } = editor.state.selection; - expect(editor.state.doc.textBetween(from, to)).toBe("linked"); - expect(editor.getAttributes("link").href).toBe("https://a.com"); - }); - }); -}); diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/LinkBubbleMenu.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/LinkBubbleMenu.spec.tsx new file mode 100644 index 0000000000..f3ac359a10 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/LinkBubbleMenu.spec.tsx @@ -0,0 +1,167 @@ +import "@testing-library/jest-dom"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { Editor } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { ReactElement } from "react"; +import { EditorContext } from "../components/EditorContext"; +import { LinkBubbleMenu } from "../components/LinkBubbleMenu"; + +// jsdom has no layout: ProseMirror's coordsAtPos (used by the BubbleMenu to position itself) +// needs Range rects, so stub them with an empty rect. +const emptyRect = (): DOMRect => + ({ x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) }) as DOMRect; +beforeAll(() => { + Range.prototype.getBoundingClientRect = emptyRect; + Range.prototype.getClientRects = (() => [emptyRect()]) as unknown as Range["getClientRects"]; +}); + +const LINK_HTML = '

linked plain

'; + +function makeEditor(content: string, editable = true): Editor { + const element = document.createElement("div"); + document.body.appendChild(element); + return new Editor({ + element, + editable, + extensions: [StarterKit.configure({ link: { openOnClick: false } })], + content + }); +} + +function withEditor(editor: Editor): ReactElement { + return ( + undefined, + dialogStyle: "inline", + imageConfig: { enableDefaultUpload: false, hasImageSource: false } + }} + > + + + ); +} + +/** Place a collapsed caret inside the first occurrence of the given text. */ +function placeCaretInText(editor: Editor, text: string): void { + let target = -1; + editor.state.doc.descendants((node, pos) => { + if (target === -1 && node.isText && node.text?.includes(text)) { + target = pos + (node.text.indexOf(text) + 1); + return false; + } + return true; + }); + act(() => { + editor.commands.setTextSelection(target); + }); +} + +const editButton = (): HTMLElement | null => screen.queryByTitle("Edit link"); +const removeButton = (): HTMLElement | null => screen.queryByTitle("Remove link"); + +// Contract: the real LinkBubbleMenu (Tiptap BubbleMenu with pluginKey "linkBubbleMenu") is attached +// to the document — and so exposes its Edit/Remove buttons — only while the caret sits inside a +// link of an editable editor and the edit dialog is not open. +describe("LinkBubbleMenu", () => { + let editor: Editor; + + afterEach(() => { + editor?.destroy(); + document.body.innerHTML = ""; + }); + + describe("visibility", () => { + it("shows Edit and Remove when the caret is inside a link", () => { + editor = makeEditor(LINK_HTML); + render(withEditor(editor)); + + placeCaretInText(editor, "linked"); + + expect(editButton()).toBeInTheDocument(); + expect(removeButton()).toBeInTheDocument(); + }); + + it("hides the menu when the caret is outside a link", () => { + editor = makeEditor(LINK_HTML); + render(withEditor(editor)); + + placeCaretInText(editor, "linked"); + placeCaretInText(editor, "plain"); + + expect(editButton()).not.toBeInTheDocument(); + expect(removeButton()).not.toBeInTheDocument(); + }); + + it("does not show the menu when the editor is not editable", () => { + editor = makeEditor(LINK_HTML, false); + render(withEditor(editor)); + + placeCaretInText(editor, "linked"); + + // The caret really is in the link; only editability keeps the menu away. + expect(editor.isActive("link")).toBe(true); + expect(editButton()).not.toBeInTheDocument(); + }); + + it("hides the menu while the edit dialog is open", async () => { + editor = makeEditor(LINK_HTML); + render(withEditor(editor)); + placeCaretInText(editor, "linked"); + + fireEvent.click(editButton()!); + // Edit extends the selection over the link; range selections are re-evaluated after + // the BubbleMenu's update delay. + await act(() => new Promise(resolve => setTimeout(resolve, 300))); + + expect(screen.getByRole("heading", { name: "Edit Link" })).toBeInTheDocument(); + expect(editButton()).not.toBeInTheDocument(); + }); + + it("renders nothing without an editor in context", () => { + const { container } = render( + undefined, + dialogStyle: "inline", + imageConfig: { enableDefaultUpload: false, hasImageSource: false } + }} + > + + + ); + + expect(container).toBeEmptyDOMElement(); + expect(editButton()).not.toBeInTheDocument(); + }); + }); + + describe("actions", () => { + it("Remove strips the whole link from a bare caret", () => { + editor = makeEditor(LINK_HTML); + render(withEditor(editor)); + placeCaretInText(editor, "linked"); + + fireEvent.click(removeButton()!); + + expect(editor.getHTML()).not.toContain(" { + editor = makeEditor(LINK_HTML); + render(withEditor(editor)); + placeCaretInText(editor, "linked"); + + fireEvent.click(editButton()!); + + expect(screen.getByRole("heading", { name: "Edit Link" })).toBeInTheDocument(); + expect(screen.getByLabelText("URL")).toHaveValue("https://a.com"); + expect(screen.getByLabelText(/^Text/)).toHaveValue("linked"); + }); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/LinkDialog.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/LinkDialog.spec.tsx new file mode 100644 index 0000000000..71386cde8a --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/__tests__/LinkDialog.spec.tsx @@ -0,0 +1,183 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Editor } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { ReactElement } from "react"; +import { EditorContext } from "../../../EditorContext"; +import { LinkDialog } from "../LinkDialog"; + +const URL_ERROR = "Enter a valid URL (http, https, mailto or tel)."; + +function makeEditor(content: string): Editor { + const element = document.createElement("div"); + document.body.appendChild(element); + return new Editor({ + element, + extensions: [StarterKit.configure({ link: { openOnClick: false } })], + content + }); +} + +function withEditor(editor: Editor, onClose: () => void): ReactElement { + return ( + undefined, + dialogStyle: "inline", + imageConfig: { enableDefaultUpload: false, hasImageSource: false } + }} + > + + + ); +} + +/** Select the first occurrence of `text` in the document. */ +function selectText(editor: Editor, text: string): void { + let from = -1; + editor.state.doc.descendants((node, pos) => { + if (from === -1 && node.isText && node.text?.includes(text)) { + from = pos + node.text.indexOf(text); + return false; + } + return true; + }); + editor.commands.setTextSelection({ from, to: from + text.length }); +} + +function fillAndSubmit(fields: { url: string; text?: string }): void { + fireEvent.change(screen.getByLabelText("URL"), { target: { value: fields.url } }); + if (fields.text !== undefined) { + fireEvent.change(screen.getByLabelText(/^Text/), { target: { value: fields.text } }); + } + fireEvent.submit(screen.getByLabelText("URL").closest("form")!); +} + +// Contract (LinkDialog handleSubmit): unsafe URL schemes are rejected with the translated +// `link.urlError` message and nothing is inserted; otherwise the link wraps the current selection, +// replaces it with new link text, or — without a selection — inserts the text (or the URL itself). +describe("LinkDialog submit", () => { + let editor: Editor; + let onClose: jest.Mock; + + beforeEach(() => { + onClose = jest.fn(); + }); + + afterEach(() => { + editor?.destroy(); + document.body.innerHTML = ""; + }); + + describe("URL validation", () => { + it.each(["javascript:alert(1)", "data:text/html,", "vbscript:msgbox(1)"])( + "rejects %s with the translated error and inserts no link", + url => { + editor = makeEditor("

hello

"); + render(withEditor(editor, onClose)); + + fillAndSubmit({ url }); + + expect(screen.getByRole("alert")).toHaveTextContent(URL_ERROR); + expect(screen.getByLabelText("URL")).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByLabelText("URL")).toHaveAttribute("aria-describedby", "link-url-error"); + expect(editor.getHTML()).not.toContain(" { + editor = makeEditor("

hello

"); + render(withEditor(editor, onClose)); + + fillAndSubmit({ url: "javascript:alert(1)" }); + fireEvent.change(screen.getByLabelText("URL"), { target: { value: "https://example.com" } }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByLabelText("URL")).not.toHaveAttribute("aria-invalid"); + }); + + it("disables submit while the URL is blank", () => { + editor = makeEditor("

hello

"); + render(withEditor(editor, onClose)); + + fireEvent.change(screen.getByLabelText("URL"), { target: { value: " " } }); + + expect(screen.getByRole("button", { name: "Insert" })).toBeDisabled(); + }); + }); + + describe("with a text selection", () => { + it("wraps the selection in a link with the href", () => { + editor = makeEditor("

hello world

"); + selectText(editor, "world"); + render(withEditor(editor, onClose)); + + expect(screen.getByLabelText(/^Text/)).toHaveValue("world"); + fillAndSubmit({ url: "https://example.com" }); + + const anchor = editor.view.dom.querySelector("a"); + expect(anchor).toHaveAttribute("href", "https://example.com"); + expect(anchor).toHaveTextContent(/^world$/); + expect(editor.getText()).toBe("hello world"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("replaces the selection with the new link text", () => { + editor = makeEditor("

hello world

"); + selectText(editor, "world"); + render(withEditor(editor, onClose)); + + fillAndSubmit({ url: "https://example.com", text: "there" }); + + const anchor = editor.view.dom.querySelector("a"); + expect(anchor).toHaveAttribute("href", "https://example.com"); + expect(anchor).toHaveTextContent(/^there$/); + expect(editor.getText()).toBe("hello there"); + }); + }); + + describe("without a selection", () => { + it("inserts the link text as a link", () => { + editor = makeEditor("

hello

"); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + render(withEditor(editor, onClose)); + + fillAndSubmit({ url: "https://example.com", text: "site" }); + + const anchor = editor.view.dom.querySelector("a"); + expect(anchor).toHaveAttribute("href", "https://example.com"); + expect(anchor).toHaveTextContent(/^site$/); + expect(editor.getText()).toBe("hellosite"); + }); + + it("inserts the URL itself as link text when no text is given", () => { + editor = makeEditor("

hello

"); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + render(withEditor(editor, onClose)); + + fillAndSubmit({ url: " https://example.com " }); + + const anchor = editor.view.dom.querySelector("a"); + expect(anchor).toHaveAttribute("href", "https://example.com"); + expect(anchor).toHaveTextContent(/^https:\/\/example\.com$/); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("applies the chosen target and title", () => { + editor = makeEditor("

hello

"); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + render(withEditor(editor, onClose)); + + fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: "Tip" } }); + fireEvent.click(screen.getByLabelText("New window")); + fillAndSubmit({ url: "https://example.com" }); + + const anchor = editor.view.dom.querySelector("a"); + expect(anchor).toHaveAttribute("target", "_blank"); + expect(anchor).toHaveAttribute("title", "Tip"); + }); + }); +}); From 46477913ddd0067ce306c2af18185fa6dd39e441 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 6 Oct 2026 12:04:29 +0200 Subject: [PATCH 5/7] test(rich-text-web): cover editor config and status bar counts Co-Authored-By: Claude Opus 5.5 --- .../__tests__/RichText.editorConfig.spec.ts | 314 ++++++++++++++++++ .../src/__tests__/RichText.spec.tsx | 5 - .../__snapshots__/RichText.spec.tsx.snap | 239 ------------- .../components/__tests__/StatusBar.spec.tsx | 74 +++++ 4 files changed, 388 insertions(+), 244 deletions(-) create mode 100644 packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.editorConfig.spec.ts create mode 100644 packages/pluggableWidgets/rich-text-web/src/components/__tests__/StatusBar.spec.tsx diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.editorConfig.spec.ts b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.editorConfig.spec.ts new file mode 100644 index 0000000000..c8b602a505 --- /dev/null +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.editorConfig.spec.ts @@ -0,0 +1,314 @@ +import { Properties } from "@mendix/pluggable-widgets-tools"; +import { RichTextPreviewProps } from "../../typings/RichTextProps"; +import { getPreview, getProperties } from "../RichText.editorConfig"; + +// The SVG jest transformer does not yield data URIs, so provide minimal ones carrying the +// placeholder label that getPreview substitutes. +jest.mock("../assets/rich-text-preview-light.svg", () => ({ + __esModule: true, + default: "data:image/svg+xml,%3Csvg%3ELIGHT%20%5BNo%20attribute%20selected%5D%3C%2Fsvg%3E" +})); +jest.mock("../assets/rich-text-preview-dark.svg", () => ({ + __esModule: true, + default: "data:image/svg+xml,%3Csvg%3EDARK%20%5BNo%20attribute%20selected%5D%3C%2Fsvg%3E" +})); + +const ALL_KEYS = [ + "stringAttribute", + "enableStatusBar", + "preset", + "toolbarLocation", + "readOnlyStyle", + "widthUnit", + "width", + "heightUnit", + "height", + "minHeightUnit", + "minHeight", + "maxHeightUnit", + "maxHeight", + "OverflowY", + "onChange", + "onFocus", + "onBlur", + "onLoad", + "onChangeType", + "spellCheck", + "linkValidation", + "defaultFontFamily", + "defaultFontSize", + "customFonts", + "imageSource", + "imageSourceContent", + "enableDefaultUpload", + "statusBarContent", + "styleDataFormat", + "dialogStyle", + "toolbarConfig", + "history", + "fontStyle", + "fontScript", + "list", + "indent", + "embed", + "align", + "code", + "fontColor", + "header", + "view", + "remove", + "tableBetter", + "helpButton", + "advancedConfig" +] as const; + +const TOOLBAR_GROUP_KEYS = [ + "history", + "fontStyle", + "fontScript", + "fontColor", + "list", + "indent", + "embed", + "align", + "code", + "header", + "remove", + "view", + "tableBetter", + "helpButton" +]; + +const HEIGHT_LIMIT_KEYS = ["minHeight", "minHeightUnit", "maxHeight", "maxHeightUnit", "OverflowY"]; + +function defaultValues(overrides: Partial = {}): RichTextPreviewProps { + return { + readOnly: false, + renderMode: "design", + translate: (text: string) => text, + stringAttribute: "Content", + enableStatusBar: true, + preset: "custom", + toolbarLocation: "auto", + readOnlyStyle: "text", + widthUnit: "percentage", + width: 100, + heightUnit: "percentageOfWidth", + height: 75, + minHeightUnit: "pixels", + minHeight: 75, + maxHeightUnit: "pixels", + maxHeight: 300, + OverflowY: "auto", + onChange: {}, + onFocus: null, + onBlur: null, + onLoad: null, + onChangeType: "onLeave", + spellCheck: true, + linkValidation: true, + defaultFontFamily: "", + defaultFontSize: "", + customFonts: [], + imageSource: { type: "Image" }, + imageSourceContent: { widgetCount: 0, renderer: () => null }, + enableDefaultUpload: true, + statusBarContent: "wordCount", + styleDataFormat: "inline", + dialogStyle: "inline", + toolbarConfig: "basic", + history: true, + fontStyle: true, + fontScript: true, + list: true, + indent: true, + embed: true, + align: true, + code: true, + fontColor: true, + header: true, + view: true, + remove: true, + tableBetter: false, + helpButton: true, + advancedConfig: [], + ...overrides + }; +} + +/** Mirrors the XML property tree as a flat group so `hidePropertiesIn` can splice from it. */ +function allProperties(): Properties { + return [ + { + caption: "All", + propertyGroups: [ + { + caption: "Flat", + properties: ALL_KEYS.map(key => ({ key, caption: key, description: "", type: "string" })) + } + ] + } + ]; +} + +function hiddenKeys(overrides: Partial = {}): string[] { + const properties = getProperties(defaultValues(overrides), allProperties()); + const visible = properties[0].propertyGroups![0].properties!.map(prop => prop.key); + return ALL_KEYS.filter(key => !visible.includes(key)); +} + +/** Keys hidden by `overrides` on top of what the default fixture already hides. */ +function newlyHidden(overrides: Partial): string[] { + const baseline = hiddenKeys(); + return hiddenKeys(overrides).filter(key => !baseline.includes(key)); +} + +// Contract: getProperties hides Studio Pro properties that have no effect for the current +// configuration, and getPreview renders the editor image plus an image-source dropzone only +// when an image source is configured. +describe("RichText editor config", () => { + describe("getProperties", () => { + it("hides only height and advancedConfig for the default fixture", () => { + // custom preset + basic toolbar config + percentageOfWidth height + expect(hiddenKeys()).toEqual(["height", "advancedConfig"]); + }); + + describe("preset", () => { + it.each(["basic", "standard", "full"] as const)("hides toolbar configuration for the %s preset", preset => { + expect(hiddenKeys({ preset })).toEqual( + expect.arrayContaining([...TOOLBAR_GROUP_KEYS, "toolbarConfig", "advancedConfig"]) + ); + expect(newlyHidden({ preset }).sort()).toEqual([...TOOLBAR_GROUP_KEYS, "toolbarConfig"].sort()); + }); + + it("shows the toolbar groups for a custom preset with the basic toolbar config", () => { + const hidden = hiddenKeys({ preset: "custom", toolbarConfig: "basic" }); + TOOLBAR_GROUP_KEYS.forEach(key => expect(hidden).not.toContain(key)); + expect(hidden).not.toContain("toolbarConfig"); + }); + }); + + describe("toolbarConfig", () => { + it("hides the toolbar groups but shows advancedConfig for the advanced config", () => { + const hidden = hiddenKeys({ toolbarConfig: "advanced" }); + expect([...newlyHidden({ toolbarConfig: "advanced" })].sort()).toEqual([...TOOLBAR_GROUP_KEYS].sort()); + expect(hidden).not.toContain("advancedConfig"); + }); + }); + + describe("height", () => { + it("hides height but keeps height limits for percentage of width", () => { + const hidden = hiddenKeys({ heightUnit: "percentageOfWidth" }); + expect(hidden).toContain("height"); + HEIGHT_LIMIT_KEYS.forEach(key => expect(hidden).not.toContain(key)); + }); + + it.each(["pixels", "percentageOfParent", "percentageOfView"] as const)( + "shows height and hides height limits for %s", + heightUnit => { + const hidden = hiddenKeys({ heightUnit }); + expect(hidden).not.toContain("height"); + expect(hidden).toEqual(expect.arrayContaining(HEIGHT_LIMIT_KEYS)); + } + ); + + it("hides minHeight when minHeightUnit is none", () => { + const hidden = hiddenKeys({ minHeightUnit: "none" }); + expect(hidden).toContain("minHeight"); + expect(hidden).not.toContain("minHeightUnit"); + }); + + it("hides maxHeight and OverflowY when maxHeightUnit is none", () => { + const hidden = hiddenKeys({ maxHeightUnit: "none" }); + expect(hidden).toEqual(expect.arrayContaining(["maxHeight", "OverflowY"])); + expect(hidden).not.toContain("maxHeightUnit"); + }); + }); + + describe("events", () => { + it("hides onChangeType when no onChange action is set", () => { + expect(newlyHidden({ onChange: null })).toEqual(["onChangeType"]); + }); + + it("shows onChangeType when an onChange action is set", () => { + expect(hiddenKeys({ onChange: {} })).not.toContain("onChangeType"); + }); + }); + + describe("toolbarLocation", () => { + it("hides preset when the toolbar is hidden", () => { + expect(newlyHidden({ toolbarLocation: "hide" })).toEqual(["preset"]); + }); + + it.each(["auto", "top", "bottom"] as const)("shows preset for %s", toolbarLocation => { + expect(hiddenKeys({ toolbarLocation })).not.toContain("preset"); + }); + }); + + describe("imageSource", () => { + it("hides image source content and default upload without an image source", () => { + expect(newlyHidden({ imageSource: null })).toEqual(["imageSourceContent", "enableDefaultUpload"]); + }); + + it("shows image source content and default upload with an image source", () => { + const hidden = hiddenKeys({ imageSource: { type: "Image" } }); + expect(hidden).not.toContain("imageSourceContent"); + expect(hidden).not.toContain("enableDefaultUpload"); + }); + }); + + describe("status bar", () => { + it("hides statusBarContent when the status bar is disabled", () => { + expect(newlyHidden({ enableStatusBar: false })).toEqual(["statusBarContent"]); + }); + + it("shows statusBarContent when the status bar is enabled", () => { + expect(hiddenKeys({ enableStatusBar: true })).not.toContain("statusBarContent"); + }); + }); + }); + + describe("getPreview", () => { + it("renders only the editor image without an image source", () => { + const preview = getPreview(defaultValues({ imageSource: null }), false) as any; + + expect(preview.type).toBe("Container"); + expect(preview.children).toHaveLength(1); + expect(preview.children[0].children[0]).toEqual(expect.objectContaining({ type: "Image", height: 150 })); + }); + + it("adds an image-source dropzone when an image source is set", () => { + const values = defaultValues({ imageSource: { type: "Image" } }); + const preview = getPreview(values, false) as any; + + expect(preview.children).toHaveLength(2); + const dropzoneRow = preview.children[1]; + expect(dropzoneRow).toEqual(expect.objectContaining({ type: "RowLayout", borders: true })); + expect(dropzoneRow.children[0]).toEqual( + expect.objectContaining({ + type: "DropZone", + property: values.imageSourceContent, + placeholder: "Place image selection widget here" + }) + ); + }); + + it("labels the preview image with the selected attribute", () => { + const preview = getPreview(defaultValues({ stringAttribute: "Description" }), false) as any; + + expect(preview.children[0].children[0].document).toBe("LIGHT [Description]"); + }); + + it("keeps the placeholder label when no attribute is selected", () => { + const preview = getPreview(defaultValues({ stringAttribute: "" }), false) as any; + + expect(preview.children[0].children[0].document).toBe("LIGHT [No attribute selected]"); + }); + + it("uses the dark variant in dark mode", () => { + const preview = getPreview(defaultValues({ stringAttribute: "Description" }), true) as any; + + expect(preview.children[0].children[0].document).toBe("DARK [Description]"); + }); + }); +}); diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx index 1075ff60c3..4fe637e104 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx @@ -93,11 +93,6 @@ describe("Rich Text", () => { expect(component.container).toMatchSnapshot(); }); - it("renders with both word and character count", () => { - const component = render(); - expect(component.container).toMatchSnapshot(); - }); - describe("does not write to the attribute on load", () => { // The editor's serialization of a stored value is rarely byte-identical to it, and // derived list marker formatting widens that gap. Writing the difference back on mount diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap b/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap index 185376be7d..b57ef1eefd 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap @@ -1017,245 +1017,6 @@ exports[`Rich Text renders with HTML character count status bar 1`] = ` `; -exports[`Rich Text renders with both word and character count 1`] = ` -
-
-
-
-
-
- - - - -
-
- -
- - -
- -
-
- - - -
-
-
- -
-
- -
-
- -
-
-
- -
-
-
-
-
-
-

- - Rich text default value - -

-

-
-

-
-
-
-
- -
-
-
-`; - exports[`Rich Text renders with character count status bar 1`] = `
); + return screen.getByRole("region", { name: "Editor status bar" }); +} + +// Contract: StatusBar renders one translated metric for the given content — words (whitespace +// separated tokens), characters of the plain text, or characters of the raw HTML — inside a +// focusable, labelled live region so screen readers hear the count change. +describe("StatusBar", () => { + describe("word count", () => { + it.each([ + [" a b ", 2], + ["", 0], + [" \n\t ", 0], + ["one", 1], + ["one two\nthree\tfour", 4] + ])("counts %j as %i words", (content, expected) => { + expect(renderStatusBar(content, "wordCount")).toHaveTextContent(`Words: ${expected}`); + }); + }); + + describe("character count", () => { + it.each([ + ["", 0], + ["abc", 3], + ["a b", 3], + ["héllo", 5] + ])("counts %j as %i characters", (content, expected) => { + expect(renderStatusBar(content, "characterCount")).toHaveTextContent(`Characters: ${expected}`); + }); + + it.todo( + "excludes trailing new lines and whitespace from the character count — bug: blocked by BEH-15 " + + '(src/components/StatusBar.tsx:22-27 counts text.length without trimEnd, so "abc\\n\\n" yields 5, ' + + "contradicting CHANGELOG 4.12.0)" + ); + }); + + describe("HTML character count", () => { + it.each([ + ["", 0], + ["

abc

", 10], + ["

a

", 25] + ])("counts %j as %i HTML characters", (content, expected) => { + expect(renderStatusBar(content, "characterCountHtml")).toHaveTextContent(`Characters (HTML): ${expected}`); + }); + }); + + describe("accessibility", () => { + it("renders a focusable, labelled, polite live region", () => { + const region = renderStatusBar("a b", "wordCount"); + + expect(region).toHaveClass("rich-text-status-bar"); + expect(region).toHaveAttribute("role", "region"); + expect(region).toHaveAttribute("aria-label", "Editor status bar"); + expect(region).toHaveAttribute("aria-live", "polite"); + expect(region).toHaveAttribute("tabindex", "0"); + }); + + it("updates the announced text when the content changes", () => { + const { rerender } = render(); + rerender(); + + expect(screen.getByRole("region", { name: "Editor status bar" })).toHaveTextContent("Words: 3"); + }); + }); +}); From deb1131b3d2c06d9ce83f9aba130fded0d7ef066 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 6 Oct 2026 15:41:40 +0200 Subject: [PATCH 6/7] test(rich-text-web): drop todo placeholders for known bugs Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/KeyboardNavigation.spec.tsx | 8 -------- .../src/__tests__/RichText.runtime.spec.tsx | 4 ---- .../src/components/__tests__/StatusBar.spec.tsx | 6 ------ .../src/extensions/__tests__/classFormat.spec.ts | 12 ------------ .../src/utils/__tests__/videoUrlPattern.spec.ts | 4 ---- 5 files changed, 34 deletions(-) diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx index 8fc696c914..1b39d1861d 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx @@ -138,10 +138,6 @@ describe("KeyboardNavigation shortcuts", () => { expect(toolbar.contains(document.activeElement)).toBe(false); }); - - it.todo( - "Escape pressed on a focused toolbar button returns focus to the editor — blocked by BEH-12: handleKeyDown in src/extensions/KeyboardNavigation.ts:89-103 only receives keys dispatched inside the editor, but only acts when focus is outside it" - ); }); // Contract (Fullscreen extension): Escape exits fullscreen only when it is active. @@ -344,8 +340,4 @@ describe("ConfigurationDropdown Enter/Escape", () => { expect(document.activeElement).not.toBe(input); }); - - it.todo( - "Escape discards the draft without committing it — bug: ConfigurationDropdown.tsx:63-66 calls blur() before the clearDraft state update applies, so onBlur (line 151) commits the draft value" - ); }); diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx index 2c9475b971..5e492c4e41 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx @@ -309,10 +309,6 @@ describe("Rich Text runtime behaviour", () => { expect(container.querySelector(".tiptap-toolbar") !== null).toBe(hasToolbar); expect(getEditorDom(container)).toHaveAttribute("contenteditable", "false"); }); - - it.todo( - "becomes non-editable when the attribute turns read-only after mount — blocked by BEH-02: Editor never calls setEditable after creation, contenteditable stays true" - ); }); describe("toolbar location", () => { diff --git a/packages/pluggableWidgets/rich-text-web/src/components/__tests__/StatusBar.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/__tests__/StatusBar.spec.tsx index e2c888ea04..e191d848c9 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/__tests__/StatusBar.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/__tests__/StatusBar.spec.tsx @@ -35,12 +35,6 @@ describe("StatusBar", () => { ])("counts %j as %i characters", (content, expected) => { expect(renderStatusBar(content, "characterCount")).toHaveTextContent(`Characters: ${expected}`); }); - - it.todo( - "excludes trailing new lines and whitespace from the character count — bug: blocked by BEH-15 " + - '(src/components/StatusBar.tsx:22-27 counts text.length without trimEnd, so "abc\\n\\n" yields 5, ' + - "contradicting CHANGELOG 4.12.0)" - ); }); describe("HTML character count", () => { diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts index 0880ab7f25..bad667f132 100644 --- a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts +++ b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts @@ -38,9 +38,6 @@ function selectAll(editor: Editor): void { let editor: Editor; afterEach(() => editor?.destroy()); -const TEXT_STYLE_BUG = - "bug: TextStyle mark only parses with a `style` attribute, so class-mode spans carrying only data-*/class are dropped on load"; - // Class mode: every text-mark extension serializes its value into a data-* attribute // plus a marker class, and never into an inline `style` for that property. Loading the // class/data form back must restore the same value so getHTML() is a fixed point. @@ -73,11 +70,6 @@ describe("text-mark extensions — class format", () => { expect(out).not.toContain("style="); }); - // textStyle-based marks (color, font family, font size) cannot be loaded from the class form. - it.todo(`TextColorClass: loads — ${TEXT_STYLE_BUG}`); - it.todo(`FontFamilyClass: loads — ${TEXT_STYLE_BUG}`); - it.todo(`FontSize: loads — ${TEXT_STYLE_BUG}`); - it.each([ { name: "setTextColor", @@ -133,10 +125,6 @@ describe("text-mark extensions — class format", () => { expect(editor.getHTML()).toBe(first); }); - it.todo( - `setTextColor/setFontFamily/setFontSize: setContent(getHTML()) round-trip keeps the mark — ${TEXT_STYLE_BUG}` - ); - it("FontSize keeps only the leading number, dropping the unit", () => { editor = makeEditor("class"); editor.commands.setContent("

x

"); diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts index fefa3ee846..ae089b9fed 100644 --- a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts +++ b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts @@ -83,10 +83,6 @@ describe("matchPattern", () => { it("always targets the provider host, even when the provider name appears in another host's path", () => { expect(matchPattern("https://evil.com/youtu.be/abc")?.url).toBe("https://www.youtube.com/embed/abc"); }); - - it.todo( - "Google Maps URL builds a valid embed URL — bug: src/utils/videoUrlPattern.ts:66 uses msid=$2 (path group, not msid group $3) and has a stray trailing double quote" - ); }); describe("getPatternMatch", () => { From 9b2a55d6861c87da4b597a9f0acdc3d6831c61d8 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 6 Oct 2026 15:53:36 +0200 Subject: [PATCH 7/7] fix(rich-text-web): build a valid google maps embed url Co-Authored-By: Claude Opus 5.5 --- packages/pluggableWidgets/rich-text-web/CHANGELOG.md | 4 ++++ .../src/utils/__tests__/videoUrlPattern.spec.ts | 7 +++++++ .../rich-text-web/src/utils/videoUrlPattern.ts | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index 988944d6cd..979f14a2d6 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where embedding a Google Maps link as a video produced an invalid embed address. + ## [5.1.0] - 2026-09-29 ### Added diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts index ae089b9fed..d45d53483b 100644 --- a/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts +++ b/packages/pluggableWidgets/rich-text-web/src/utils/__tests__/videoUrlPattern.spec.ts @@ -54,6 +54,13 @@ describe("matchPattern", () => { w: 480, h: 270 }, + { + name: "Google Maps URL", + input: "https://maps.google.com/maps/ms?ie=UTF8&msa=0&msid=210840796990036384893.00047ac4ee2b8b5d0b2b5", + url: "https://maps.google.com/maps/ms?msid=210840796990036384893.00047ac4ee2b8b5d0b2b5&output=embed", + w: 425, + h: 350 + }, { name: "http input keeps http", input: "http://youtu.be/abc123", diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/videoUrlPattern.ts b/packages/pluggableWidgets/rich-text-web/src/utils/videoUrlPattern.ts index 17c246349e..e2e6ba23ba 100644 --- a/packages/pluggableWidgets/rich-text-web/src/utils/videoUrlPattern.ts +++ b/packages/pluggableWidgets/rich-text-web/src/utils/videoUrlPattern.ts @@ -63,7 +63,7 @@ const urlPatterns: UrlPattern[] = [ type: "iframe", w: 425, h: 350, - url: 'maps.google.com/maps/ms?msid=$2&output=embed"' + url: "maps.google.com/maps/ms?msid=$3&output=embed" }, { regex: /dailymotion\.com\/video\/([^_]+)/,