diff --git a/CHANGELOG.md b/CHANGELOG.md index c6761a1..322c245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- **Adaptive Cards: app text is never Markdown.** A `TextBlock` or `Fact` + whose text has Markdown syntax is a `RichTextBlock` `TextRun` now: a + model value or toast like `[verify your account](https://evil.example)` + was a live link in Teams and Copilot. +- **`htmlToText` is linear.** Its patterns scanned from every `<` to the + end of the text (and the ` tried every "<" to the end of the text - quadratic, + // 100k characters of "<" held the renderer for seconds + return dropBlocks(String(html || "")) + .replace(/<(br|\/p|\/li|\/div|\/h[1-6])\b[^<>]*>/gi, "\n") + .replace(/]*>/gi, "- ") + .replace(/<[^<>]*>/g, "") .replace(/ /g, " ") .replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&") .split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n"); diff --git a/test/adaptive-cards.test.mjs b/test/adaptive-cards.test.mjs index b28a737..0223490 100644 --- a/test/adaptive-cards.test.mjs +++ b/test/adaptive-cards.test.mjs @@ -8,7 +8,7 @@ import fs from "node:fs"; import path from "node:path"; import { test } from "node:test"; import { - renderResponses, submitToRequest, createCardHost, cardText, walk, CONTROLS, TOLERATED, CARD_VERSION, + renderResponses, submitToRequest, createCardHost, cardText, walk, htmlToText, CONTROLS, TOLERATED, CARD_VERSION, } from "../renderers/adaptive-cards/index.mjs"; import { renderMapping, apply, README } from "../scripts/render-adaptive-cards.mjs"; import { startMock } from "../conformance/frontend/index.mjs"; @@ -127,6 +127,41 @@ test("the cards say what the views say", () => { assert.deepEqual(unsupported.map((u) => u.control), ["com.example.unknown.Gadget"]); }); +test("app text is never Markdown: a text or fact with Markdown syntax is a TextRun", () => { + const link = "Customer [click me](https://evil.example)"; + const xml = '' + + '<Text text="plain"/>' + + '<List items="{/L}"><StandardListItem title="{T}" description="{D}"/></List></Page></mvc:View>'; + const response = { + S_FRONT: { ID: "1", APP: "Z_T", S_ACTION: { T_SYSTEM: [["VIEW_SLOTS", "display", "MAIN", xml]] } }, + MODEL: { C: link, L: [{ T: "a", D: "[x](https://evil.example)" }] }, + }; + const toast = "**[verify your account](https://evil.example/login)**"; + const { card } = renderResponses([response], { messages: [{ kind: "toast", text: toast }] }); + assert.deepEqual(cardProblems(card), []); + const blocks = []; + const runs = []; + walk(card.body, (e) => { + if (e.type === "TextBlock") blocks.push(e.text); + if (e.type === "FactSet") blocks.push(...e.facts.flatMap((f) => [f.title, f.value])); + if (e.type === "RichTextBlock") runs.push(...e.inlines.map((i) => i.text)); + }); + assert.ok(blocks.includes("plain"), "a plain text stays a TextBlock"); + assert.ok(!blocks.some((t) => /\]\(|\*\*/.test(t)), `no Markdown reaches a TextBlock or a Fact: ${blocks.join(" | ")}`); + for (const t of [link, "**bold** title", toast, "[x](https://evil.example)"]) assert.ok(runs.includes(t), `${t} as a TextRun`); +}); + +test("htmlToText: script and style dropped to their closing tag, and linear on unclosed markup", () => { + assert.equal(htmlToText("<p>a</p><script>x<b>y</b></script><STYLE>z</style >b<br>c<br><ul><li>d</li></ul>"), "a\nb\nc\n- d"); + assert.equal(htmlToText("a < b <i>c</i>"), "a < b c", "a lone < is text"); + assert.equal(htmlToText("<script>never closed <b>x</b>"), "never closed x", "an unclosed block stays"); + for (const flood of ["<".repeat(100000), "<script".repeat(20000), "<br".repeat(30000)]) { + const t0 = Date.now(); + htmlToText(flood); + assert.ok(Date.now() - t0 < 500, `${flood.slice(0, 8)}...: ${Date.now() - t0} ms`); + } +}); + // ---------------------------------------------------- the reverse step ---- test("an Action.Submit payload becomes the next request: changed inputs as the model delta", () => {