Files
PleinR/tests/rich-text-dom.test.ts
T
Claude cc33cf5fbc Informations : éditeur visuel à la place de la syntaxe Markdown
La barre d'outils écrivait des étoiles dans un textarea : l'adhérent qui
rédigeait devait connaître une syntaxe pour obtenir du gras. On édite
désormais comme dans un traitement de texte — on sélectionne, on clique sur
G, le texte devient gras à l'écran.

Le format stocké ne change pas d'un octet. C'est tout l'enjeu : le WYSIWYG
porte sur la saisie, pas sur le stockage. À chaque frappe, le navigateur
retraverse ce qu'il a édité (serializeToRichText) et n'en garde que le gras,
l'italique, les listes, les sous-titres et les liens ; le balisage part dans
un champ caché et le serveur le ré-analyse avec l'analyseur existant. Le HTML
de l'éditeur ne quitte jamais la page, donc aucun assainisseur HTML n'entre
dans le produit — le risque n'a jamais été la surface d'édition, mais la base.

- rich-text-dom.ts : sérialiseur pur, testable sans navigateur (MinimalNode).
  Il lit aussi le gras codé en style CSS, pour qu'un brouillon collé depuis
  Google Docs ne perde pas sa mise en forme ; le style explicite l'emporte sur
  la balise, sinon le <b style="font-weight:normal"> dont Docs enveloppe tout
  document mettrait le texte entier en gras.
- Un collage passe par DOMParser, dont le document est inerte : un <img
  onerror> collé ne s'exécute pas. Seul du HTML produit par nous est réinjecté.
- L'italique s'écrit _ainsi_ : **_x_** est lisible par l'analyseur là où ***x***
  serait ambigu. Un tiret bas au milieu d'un mot n'ouvre rien.
- saveInformation normalise les CRLF que l'encodage des formulaires introduit.

Vérifié au navigateur contre une base réelle : aller-retour identique après
enregistrement, et un collage hostile (script, img onerror, iframe, style,
lien javascript:) ressort en texte structuré sans qu'une seule alerte parte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
2026-09-15 06:37:52 +00:00

205 lines
7.9 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { serializeToRichText, type MinimalNode } from "../src/lib/rich-text-dom";
import { parseRichText, richTextToEmailHtml, richTextToPlain } from "../src/lib/rich-text";
/**
* Faux DOM minimal : le sérialiseur ne connaît du navigateur que `MinimalNode`,
* ce qui permet de le tester sans jsdom.
*/
function el(name: string, children: MinimalNode[] = [], attributes: Record<string, string> = {}): MinimalNode {
return {
nodeType: 1,
nodeName: name,
childNodes: children,
getAttribute: (key: string) => attributes[key] ?? null,
};
}
function txt(value: string): MinimalNode {
return { nodeType: 3, nodeName: "#text", textContent: value, childNodes: [] };
}
const root = (...children: MinimalNode[]) => el("DIV", children);
test("un paragraphe simple ressort tel quel", () => {
assert.equal(serializeToRichText(root(el("P", [txt("Bonjour à tous.")]))), "Bonjour à tous.");
});
test("deux paragraphes sont séparés par une ligne vide", () => {
const markup = serializeToRichText(root(el("P", [txt("Premier.")]), el("P", [txt("Second.")])));
assert.equal(markup, "Premier.\n\nSecond.");
assert.equal(parseRichText(markup).length, 2);
});
test("le gras et l'italique deviennent des marqueurs", () => {
assert.equal(
serializeToRichText(root(el("P", [txt("Rendez-vous "), el("STRONG", [txt("mardi")]), txt(" à "), el("EM", [txt("Pompey")])]))),
"Rendez-vous **mardi** à _Pompey_",
);
});
test("les balises B et I d'execCommand valent STRONG et EM", () => {
assert.equal(serializeToRichText(root(el("P", [el("B", [txt("a")]), el("I", [txt("b")])]))), "**a**_b_");
});
test("gras et italique imbriqués se relisent correctement", () => {
// C'est la raison du tiret bas : « ***x*** » serait ambigu pour l'analyseur.
const markup = serializeToRichText(root(el("P", [el("STRONG", [el("EM", [txt("très important")])])])));
assert.equal(markup, "**_très important_**");
const blocks = parseRichText(markup);
assert.equal(blocks[0].kind, "paragraph");
const strong = blocks[0].kind === "paragraph" ? blocks[0].children[0] : null;
assert.equal(strong?.kind, "strong");
assert.equal(strong?.kind === "strong" ? strong.children[0].kind : null, "em");
});
test("un saut de ligne dans un paragraphe est conservé", () => {
const markup = serializeToRichText(root(el("P", [txt("Une ligne"), el("BR"), txt("une autre")])));
assert.equal(markup, "Une ligne\nune autre");
assert.equal(parseRichText(markup).length, 1);
});
test("les listes à puces et numérotées prennent leur préfixe", () => {
assert.equal(
serializeToRichText(root(el("UL", [el("LI", [txt("un")]), el("LI", [txt("deux")])]))),
"- un\n- deux",
);
assert.equal(
serializeToRichText(root(el("OL", [el("LI", [txt("a")]), el("LI", [txt("b")]), el("LI", [txt("c")])]))),
"1. a\n2. b\n3. c",
);
});
test("la numérotation repart à un sur une seconde liste", () => {
const markup = serializeToRichText(
root(
el("OL", [el("LI", [txt("a")])]),
el("P", [txt("Entre les deux.")]),
el("OL", [el("LI", [txt("b")])]),
),
);
assert.equal(markup, "1. a\n\nEntre les deux.\n\n1. b");
});
test("un titre devient un sous-titre", () => {
assert.equal(serializeToRichText(root(el("H4", [txt("Ordre du jour")]))), "## Ordre du jour");
// Les autres niveaux de titre, venus d'un collage, se rabattent sur le même.
assert.equal(serializeToRichText(root(el("H1", [txt("Collé depuis le web")]))), "## Collé depuis le web");
});
test("un lien http(s) garde sa cible, les autres perdent la leur", () => {
assert.equal(
serializeToRichText(root(el("P", [el("A", [txt("le site")], { href: "https://pleinr.fr/" })]))),
"[le site](https://pleinr.fr/)",
);
assert.equal(
serializeToRichText(root(el("P", [el("A", [txt("piège")], { href: "javascript:alert(1)" })]))),
"piège",
);
assert.equal(serializeToRichText(root(el("P", [el("A", [txt("sans cible")])]))), "sans cible");
});
test("tout ce qui n'est pas reconnu perd sa balise et garde son texte", () => {
// Couleur, taille, police : rien de tout cela n'existe dans le format stocké.
const markup = serializeToRichText(
root(el("P", [el("SPAN", [txt("du texte")], { style: "color:red;font-size:32px" }), el("FONT", [txt(" coloré")], { color: "blue" })])),
);
assert.equal(markup, "du texte coloré");
});
test("le contenu exécutable est retiré, texte compris", () => {
const markup = serializeToRichText(
root(
el("SCRIPT", [txt("alert(1)")]),
el("STYLE", [txt("body{display:none}")]),
el("P", [txt("Bonjour"), el("IFRAME", [txt("piège")])]),
),
);
assert.equal(markup, "Bonjour");
assert.ok(!markup.includes("alert"));
assert.ok(!markup.includes("display:none"));
});
test("un collage de Word — div imbriqués et styles — se ramène au texte structuré", () => {
const pasted = root(
el("DIV", [
el("P", [el("SPAN", [txt("Compte rendu")], { style: "font-size:24px" })]),
el("UL", [el("LI", [el("SPAN", [el("B", [txt("Point 1")])])]), el("LI", [txt("Point 2")])]),
]),
);
assert.equal(serializeToRichText(pasted), "Compte rendu\n\n- **Point 1**\n- Point 2");
});
test("les espaces insécables de l'éditeur redeviennent des espaces", () => {
assert.equal(serializeToRichText(root(el("P", [txt("19\u00a0h\u00a0précises")]))), "19 h précises");
});
test("une mise en forme appliquée à une sélection vide ne produit pas de marqueur", () => {
// Chrome laisse un `<b></b>` quand on active le gras sans rien taper.
assert.equal(serializeToRichText(root(el("P", [el("B", []), txt("texte")]))), "texte");
assert.ok(!serializeToRichText(root(el("P", [el("B", [txt(" ")]), txt("x")]))).includes("**"));
});
test("un éditeur vide produit une chaîne vide", () => {
assert.equal(serializeToRichText(root()), "");
assert.equal(serializeToRichText(root(el("P", [el("BR")]))), "");
assert.equal(serializeToRichText(root(el("P", [txt(" ")]))), "");
});
test("l'aller-retour édition → stockage → rendu conserve le sens", () => {
const markup = serializeToRichText(
root(
el("H4", [txt("Assemblée générale")]),
el("P", [txt("Elle se tiendra "), el("STRONG", [txt("salle Jean-Jaurès")]), txt(".")]),
el("UL", [el("LI", [txt("comptes 2026")]), el("LI", [txt("budget")])]),
el("P", [el("A", [txt("le compte rendu")], { href: "https://pleinr.fr/association" })]),
),
);
assert.equal(
markup,
"## Assemblée générale\n\nElle se tiendra **salle Jean-Jaurès**.\n\n- comptes 2026\n- budget\n\n[le compte rendu](https://pleinr.fr/association)",
);
const html = richTextToEmailHtml(markup);
assert.ok(html.includes("<strong"));
assert.ok(html.includes("<ul"));
assert.ok(html.includes('href="https://pleinr.fr/association"'));
assert.ok(richTextToPlain(markup).includes("Assemblée générale"));
});
test("le gras codé en style CSS est reconnu — cas Google Docs", () => {
assert.equal(
serializeToRichText(root(el("P", [el("SPAN", [txt("important")], { style: "font-weight:700" })]))),
"**important**",
);
assert.equal(
serializeToRichText(root(el("P", [el("SPAN", [txt("nuance")], { style: "font-style: italic" })]))),
"_nuance_",
);
});
test("le style explicite l'emporte sur la balise", () => {
// Google Docs enveloppe tout le document dans un `<b style="font-weight:normal">`.
assert.equal(
serializeToRichText(root(el("B", [el("P", [txt("tout le document")])], { style: "font-weight:normal" }))),
"tout le document",
);
});
test("un marqueur n'est jamais doublé", () => {
assert.equal(
serializeToRichText(root(el("P", [el("B", [el("SPAN", [txt("x")], { style: "font-weight:bold" })])]))),
"**x**",
);
});
test("gras et italique combinés s'imbriquent dans le bon ordre", () => {
assert.equal(
serializeToRichText(root(el("P", [el("SPAN", [txt("les deux")], { style: "font-weight:700;font-style:italic" })]))),
"**_les deux_**",
);
});