switchroom 0.21.8 → 0.21.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +100 -39
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/skills/switchroom-architecture/telegram.md +12 -10
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/README.md +3 -1
- package/telegram-plugin/dist/gateway/gateway.js +919 -348
- package/telegram-plugin/format.ts +30 -5
- package/telegram-plugin/gateway/outbound-send-path.ts +7 -0
- package/telegram-plugin/gateway/speech-capture.ts +158 -0
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/render/code-segments.ts +38 -4
- package/telegram-plugin/render/dollar-math-guard.ts +16 -1
- package/telegram-plugin/render/html-fold.ts +354 -0
- package/telegram-plugin/render/ir.ts +53 -3
- package/telegram-plugin/render/parse.ts +642 -42
- package/telegram-plugin/render/render.ts +53 -15
- package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
- package/telegram-plugin/rich-send.ts +22 -7
- package/telegram-plugin/shared/bot-runtime.ts +3 -2
- package/telegram-plugin/telegraph.ts +6 -4
- package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
- package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +253 -0
- package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
- package/telegram-plugin/tests/render/parse.test.ts +39 -10
- package/telegram-plugin/tests/render/render.test.ts +9 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
- package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
- package/telegram-plugin/tests/send-reply-golden.test.ts +99 -1
- package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
- package/telegram-plugin/tests/speech-capture.test.ts +296 -0
- package/telegram-plugin/tests/telegraph.test.ts +1 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
- package/telegram-plugin/tts-normalize.ts +47 -9
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
- package/telegram-plugin/voice-normalize-text.ts +48 -9
|
@@ -135,4 +135,106 @@ describe("guardAccidentalFormatting composition (#3252)", () => {
|
|
|
135
135
|
// And re-wrapping (streaming re-render) is byte-stable.
|
|
136
136
|
expect(richMessage(wire.markdown).markdown).toBe(wire.markdown);
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
// ── Wire-verified NATIVE constructs survive the FULL pipeline ────────────
|
|
140
|
+
// Raw sendRichMessage probes (2026-08-13) proved Telegram's rich markdown
|
|
141
|
+
// path natively renders `<details>`, `$…$` inline math, and footnotes. The
|
|
142
|
+
// pre-fix pipeline destroyed all three (details → the unsupported `**>`
|
|
143
|
+
// marker, `$x^2+y^2$` → `\$x^2+y^2\$`, `[^n1]` deleted) — each assertion
|
|
144
|
+
// below FAILS on that pipeline.
|
|
145
|
+
describe("native constructs pass through the full composed guard", () => {
|
|
146
|
+
it("<details open><summary> block survives byte-identical", () => {
|
|
147
|
+
const src = "<details open><summary>S</summary>\n\nbody\n\n</details>";
|
|
148
|
+
expect(guardAccidentalFormatting(src)).toBe(src);
|
|
149
|
+
expect(richMessage(src).markdown).toBe(src);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("compact $math$ span survives byte-identical", () => {
|
|
153
|
+
const src = "inline $x^2+y^2$ done";
|
|
154
|
+
expect(guardAccidentalFormatting(src)).toBe(src);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("footnote reference + definition survive byte-identical", () => {
|
|
158
|
+
const src = "claim[^n1]\n\n[^n1]: body";
|
|
159
|
+
expect(guardAccidentalFormatting(src)).toBe(src);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("`**>` is never emitted for any input the pipeline repairs", () => {
|
|
163
|
+
const out = guardAccidentalFormatting(
|
|
164
|
+
"<details><summary>T</summary>\nbody\n</details>",
|
|
165
|
+
);
|
|
166
|
+
expect(out).not.toContain("**>");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── Guard-interaction cases for the now-passing-through constructs ───────
|
|
171
|
+
// Removing the old token conversions changes what the sibling guards see (a
|
|
172
|
+
// surviving `$`, a surviving `<`). These prove the emphasis / heading /
|
|
173
|
+
// block-construct / inline-pair / dollar guards do not mangle the natives,
|
|
174
|
+
// while their own repairs still fire in the SAME message.
|
|
175
|
+
describe("native constructs vs sibling guards (interaction)", () => {
|
|
176
|
+
it("math span is exempt while surrounding currency is still broken apart", () => {
|
|
177
|
+
const src = "sum $x^2+y^2$ costs $5 and $10 total";
|
|
178
|
+
const out = guardAccidentalFormatting(src);
|
|
179
|
+
// The math span reaches the wire byte-identical…
|
|
180
|
+
expect(out).toContain("$x^2+y^2$");
|
|
181
|
+
// …while the accidental currency pair is still defused (#3252).
|
|
182
|
+
expect(out).toContain("\$5");
|
|
183
|
+
expect(out).toContain("\$10");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("currency-shaped adjacent amounts are NOT mistaken for math", () => {
|
|
187
|
+
// `$5M-$10M` has a whitespace-free `$…$` pair (`$5M-$`) — the currency
|
|
188
|
+
// shape must keep it guardable, else #3252 regresses.
|
|
189
|
+
const out = guardAccidentalFormatting("range is $5M-$10M this year");
|
|
190
|
+
expect(out).not.toMatch(/(?<!\\)\$/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("math span interior is protected from the emphasis and caret guards", () => {
|
|
194
|
+
// `a_b*c` inside math would trip the intra-word `_`/`*` escapes, and
|
|
195
|
+
// `^2y^` is an alphanumeric caret pair the token guard would strip —
|
|
196
|
+
// both must stay verbatim inside the protected span. The `_`/`*`
|
|
197
|
+
// OUTSIDE the span are still guarded — note the protected span's
|
|
198
|
+
// delimiters do NOT count toward the 2+ pairing threshold, so the
|
|
199
|
+
// prose needs its own pairable signals (`2*3 and 4*5`).
|
|
200
|
+
const src = "$a_b*c$ and file_name_here times 2*3 and 4*5";
|
|
201
|
+
const out = guardAccidentalFormatting(src);
|
|
202
|
+
expect(out).toContain("$a_b*c$");
|
|
203
|
+
expect(out).toContain("file\\_name\\_here");
|
|
204
|
+
expect(out).toContain("2\\*3");
|
|
205
|
+
expect(out).toContain("4\\*5");
|
|
206
|
+
expect(guardAccidentalFormatting("inline $x^2y^2$ done")).toBe(
|
|
207
|
+
"inline $x^2y^2$ done",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("<details> lines are invisible to the line-start and heading guards", () => {
|
|
212
|
+
// A details block whose body carries genuine accidental signals: the
|
|
213
|
+
// guards must fire on the BODY prose without touching the tags.
|
|
214
|
+
const src =
|
|
215
|
+
"<details open><summary>Stats</summary>\n\n>2x growth and #1 priority\n\n</details>";
|
|
216
|
+
const out = guardAccidentalFormatting(src);
|
|
217
|
+
expect(out).toContain("<details open><summary>Stats</summary>");
|
|
218
|
+
expect(out).toContain("</details>");
|
|
219
|
+
// Line-start `>2x` and glued `#1` are still repaired inside the body.
|
|
220
|
+
expect(out).toContain("\>2x");
|
|
221
|
+
expect(out).toContain("\#1");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("footnote markers survive alongside a firing dollar guard", () => {
|
|
225
|
+
const src = "cost[^1] was $5 then $9\n\n[^1]: the note";
|
|
226
|
+
const out = guardAccidentalFormatting(src);
|
|
227
|
+
expect(out).toContain("cost[^1]");
|
|
228
|
+
expect(out).toContain("[^1]: the note");
|
|
229
|
+
expect(out).toContain("\$5");
|
|
230
|
+
expect(out).toContain("\$9");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("the whole composition stays idempotent with natives present", () => {
|
|
234
|
+
const src =
|
|
235
|
+
"claim[^1] and $x^2+y^2$ in <details><summary>T</summary>\nb\n</details> with $5 and $10\n\n[^1]: n";
|
|
236
|
+
const once = guardAccidentalFormatting(src);
|
|
237
|
+
expect(guardAccidentalFormatting(once)).toBe(once);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
138
240
|
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Outcome tests for the content-loss regressions found in the raw-HTML
|
|
2
|
+
// dialect fold (#4691, merged as fa9018a7, unreleased).
|
|
3
|
+
//
|
|
4
|
+
// Every assertion here FAILS on fa9018a7. They drive the real `renderOutbound`
|
|
5
|
+
// (`parse` -> `renderSafe`), i.e. exactly what the live send path calls, so
|
|
6
|
+
// they assert the WIRE OUTCOME rather than an internal code path.
|
|
7
|
+
//
|
|
8
|
+
// The through-line: fa9018a7's degrade bucket DELETED any token it could not
|
|
9
|
+
// fold or pass through. That is safe for markup but catastrophic for prose —
|
|
10
|
+
// `<service>/<key>`, `<agent>` and "the `<b>` tag" are pervasive in this
|
|
11
|
+
// project's own agent output, and they were silently vanishing from replies.
|
|
12
|
+
// The discriminator is now MATCHING, not the tag name: a balanced pair is
|
|
13
|
+
// markup (drop it, keep the content), an unmatched marker is prose (keep it,
|
|
14
|
+
// HTML-entity-escaped so the wire cannot read it as a tag).
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from "vitest";
|
|
17
|
+
import { renderOutbound } from "../../render/rich-render.js";
|
|
18
|
+
import { parse } from "../../render/parse.js";
|
|
19
|
+
import { escapeLinkHref } from "../../format.js";
|
|
20
|
+
|
|
21
|
+
const render = (src: string): string => renderOutbound(src).text;
|
|
22
|
+
|
|
23
|
+
describe("BLOCKER: angle-bracket placeholders in prose survive (#4691 regression)", () => {
|
|
24
|
+
it("keeps a `<service>/<key>` convention line intact", () => {
|
|
25
|
+
// fa9018a7: "Use the / convention."
|
|
26
|
+
expect(render("Use the <service>/<key> convention.")).toBe(
|
|
27
|
+
"Use the <service>/<key> convention.",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps a trailing `<key>` placeholder on a command line", () => {
|
|
32
|
+
// fa9018a7: "run switchroom vault get "
|
|
33
|
+
expect(render("run switchroom vault get <key>")).toBe(
|
|
34
|
+
"run switchroom vault get <key>",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps an `<agent>` path segment", () => {
|
|
39
|
+
// fa9018a7: "logs live at /host-home/.switchroom/logs//"
|
|
40
|
+
expect(render("logs live at /host-home/.switchroom/logs/<agent>/")).toBe(
|
|
41
|
+
"logs live at /host-home/.switchroom/logs/<agent>/",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps a tag NAMED in prose when nothing closes it", () => {
|
|
46
|
+
// fa9018a7: "The tag is bold and is italic"
|
|
47
|
+
expect(render("The <b> tag is bold and <i> is italic")).toBe(
|
|
48
|
+
"The <b> tag is bold and <i> is italic",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("never emits a bare `<` for a token it kept as prose", () => {
|
|
53
|
+
// The escape is what makes keeping the token safe: Rich HTML documents
|
|
54
|
+
// `<`/`>`/`&` as supported named entities, so this cannot trip
|
|
55
|
+
// `unsupported start tag` the way a bare `<service>` would.
|
|
56
|
+
const out = render("Use <service>/<key> and <agent>.");
|
|
57
|
+
expect(out).not.toMatch(/<[A-Za-z/]/);
|
|
58
|
+
expect(out).toBe("Use <service>/<key> and <agent>.");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not double-escape when its own output is re-parsed", () => {
|
|
62
|
+
// Re-parsing decodes `<` back to a text-node `<`, which never reaches
|
|
63
|
+
// the fold at all (the documented residual in html-fold.ts's header), so
|
|
64
|
+
// the second pass is NOT byte-idempotent. What must never happen is a
|
|
65
|
+
// compounding `&lt;` that the reader actually sees.
|
|
66
|
+
const once = render("Use the <service>/<key> convention.");
|
|
67
|
+
expect(once).toBe("Use the <service>/<key> convention.");
|
|
68
|
+
expect(render(once)).not.toContain("&");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("still DROPS the markup of a balanced unknown pair (content kept)", () => {
|
|
72
|
+
// The other half of the discriminator: a matched pair really is markup.
|
|
73
|
+
expect(render("<marquee>scrolling</marquee>")).toBe("scrolling");
|
|
74
|
+
expect(render(`<span data-x="1">t</span>`)).toBe("t");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("MAJOR 1: `<pre>` folds to a fenced block, not a broken code span", () => {
|
|
79
|
+
it("renders `<pre><code>` as a real fence", () => {
|
|
80
|
+
// fa9018a7: "`const a = 1;\nconst b = 2;`" — a backtick span wrapping a
|
|
81
|
+
// newline, which Telegram will not parse, so the reader sees literal
|
|
82
|
+
// backticks around broken text.
|
|
83
|
+
expect(render("<pre><code>const a = 1;\nconst b = 2;</code></pre>")).toBe(
|
|
84
|
+
"```\nconst a = 1;\nconst b = 2;\n```",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("never emits a code span containing a newline", () => {
|
|
89
|
+
const out = render("<pre><code>a\nb</code></pre>");
|
|
90
|
+
expect(out).not.toMatch(/(^|[^`])`[^`]*\n[^`]*`/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("carries the `class=\"language-…\"` hint into the fence info string", () => {
|
|
94
|
+
expect(
|
|
95
|
+
render(`<pre><code class="language-python">print(1)</code></pre>`),
|
|
96
|
+
).toBe("```python\nprint(1)\n```");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("handles a bare `<pre>` with no inner `<code>`", () => {
|
|
100
|
+
expect(render("<pre>one\ntwo</pre>")).toBe("```\none\ntwo\n```");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("leaves no stray leading or trailing newline around the fence", () => {
|
|
104
|
+
// The layout newlines a `<pre>` block carries are not content.
|
|
105
|
+
expect(render("<pre>\nbody\n</pre>")).toBe("```\nbody\n```");
|
|
106
|
+
expect(render("<pre>\nbody\n</pre>")).not.toMatch(/^\n|\n$/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("degrades to an inline code span where a fence cannot survive", () => {
|
|
110
|
+
// A fence inside a `#` line or a table cell would end the block/row.
|
|
111
|
+
expect(render("# a <pre>x</pre> b")).toBe("# a `x` b");
|
|
112
|
+
expect(render("| h |\n| --- |\n| <pre>x</pre> |")).toContain("| `x` |");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("MAJOR 2: structural tags do not glue their content together", () => {
|
|
117
|
+
it("separates list items instead of emitting `onetwo`", () => {
|
|
118
|
+
expect(render("<ul><li>one</li><li>two</li></ul>")).toBe("one \ntwo");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("separates paragraphs instead of emitting `para onepara two`", () => {
|
|
122
|
+
expect(render("<p>para one</p><p>para two</p>")).toBe(
|
|
123
|
+
"para one \npara two",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("separates table cells", () => {
|
|
128
|
+
expect(render("<table><tr><td>a</td><td>b</td></tr></table>")).toBe(
|
|
129
|
+
"a \nb",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("leaves no separator hanging at the edges of the block", () => {
|
|
134
|
+
const out = render("<div>only</div>");
|
|
135
|
+
expect(out).toBe("only");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("still renders a markup-only block to nothing", () => {
|
|
139
|
+
// Regression guard for the separator: an empty structural block must not
|
|
140
|
+
// become a whitespace-only paragraph and a spurious `\n\n`.
|
|
141
|
+
expect(render("a\n\n<div></div>\n\nb")).toBe("a\n\nb");
|
|
142
|
+
expect(render("a\n\n<!-- c -->\n\nb")).toBe("a\n\nb");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("uses a space, not a newline, inside a table cell", () => {
|
|
146
|
+
// A newline in a cell ends the row.
|
|
147
|
+
const out = render("| h |\n| --- |\n| <p>x</p><p>y</p> |");
|
|
148
|
+
expect(out).toContain("| x y |");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("MAJOR 3: the passthrough bucket is balance-checked", () => {
|
|
153
|
+
it("does not ship an unmatched `<u>` raw", () => {
|
|
154
|
+
// fa9018a7 emitted this verbatim -> `unclosed start tag` 400 -> the
|
|
155
|
+
// fallback resends the WHOLE message as plain text. One stray marker in
|
|
156
|
+
// relayed content was a denial-of-formatting on the agent's reply.
|
|
157
|
+
expect(render("hello <u>world")).toBe("hello <u>world");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("does not ship a stray close marker raw", () => {
|
|
161
|
+
expect(render("</details>")).toBe("</details>");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("keeps the balanced inner pair and escapes only the unmatched outer", () => {
|
|
165
|
+
expect(render("<u><u>x</u>")).toBe("<u><u>x</u>");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("still passes a BALANCED allowlist tag through raw", () => {
|
|
169
|
+
for (const src of [
|
|
170
|
+
"<u>under</u>",
|
|
171
|
+
"H<sub>2</sub>O",
|
|
172
|
+
"x<sup>2</sup>",
|
|
173
|
+
"<aside><cite>note</cite></aside>",
|
|
174
|
+
]) {
|
|
175
|
+
expect(render(src)).toBe(src);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("passes a `<details>` block through even though it spans mdast blocks", () => {
|
|
180
|
+
// The balance check MUST be document-level: micromark splits this into
|
|
181
|
+
// three separate `html` nodes, so a per-node check would wrongly call the
|
|
182
|
+
// open and close markers unmatched and escape them.
|
|
183
|
+
const src = "<details open><summary>S</summary>\n\nbody\n\n</details>";
|
|
184
|
+
expect(render(src)).toBe(src);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("does not let a tag inside a code fence balance a prose marker", () => {
|
|
188
|
+
// Tags in a fence are not markup and must not satisfy anything outside it.
|
|
189
|
+
const out = render("```\n</u>\n```\n\nhello <u>world");
|
|
190
|
+
expect(out).toContain("```\n</u>\n```");
|
|
191
|
+
expect(out).toContain("hello <u>world");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("MAJOR 4: the fence comment's claimed safety property (pinned)", () => {
|
|
196
|
+
it("PINS the lazy-continuation counterexample the comment gets wrong", () => {
|
|
197
|
+
// The `**>` rewrite tracks TOP-LEVEL fences only, so the `> ``` ` opener
|
|
198
|
+
// here is invisible to it and the column-0 `**> lazy` line is rewritten,
|
|
199
|
+
// eating the `**`. CommonMark forbids lazy continuation into fenced code,
|
|
200
|
+
// so that line should CLOSE the blockquote and stay literal.
|
|
201
|
+
//
|
|
202
|
+
// This is PRE-EXISTING and identical before #4691 — NOT a regression — and
|
|
203
|
+
// fixing it needs real container tracking, not a wider fence regex. Pinned
|
|
204
|
+
// so any future container-aware rewrite has to decide it deliberately;
|
|
205
|
+
// `FENCE_DELIM_RE`'s doc comment now describes this instead of claiming
|
|
206
|
+
// the case cannot happen.
|
|
207
|
+
expect(render("> ```\n**> lazy\n> ```")).toBe("> ```\n> lazy\n> ```");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("nits: breaks, nested links, and href hygiene", () => {
|
|
212
|
+
it("does not break a heading block with `<br>`", () => {
|
|
213
|
+
// fa9018a7: "# a \nb" — the hard break ends the heading and orphans `b`.
|
|
214
|
+
expect(render("# a<br>b")).toBe("# a b");
|
|
215
|
+
expect(parse("# a<br>b").blocks).toHaveLength(1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("keeps a `<br>` a real break in ordinary prose", () => {
|
|
219
|
+
expect(render("one<br>two")).toBe("one \ntwo");
|
|
220
|
+
// Two explicit breaks are the author's, not synthesized separators — they
|
|
221
|
+
// must not be collapsed by the separator normalizer.
|
|
222
|
+
expect(render("a<br><br>b")).toBe("a \n \nb");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("does not silently drop a nested `<a>`'s href", () => {
|
|
226
|
+
// fa9018a7: "[out in](https://o.example)" — the inner URL vanished.
|
|
227
|
+
const out = render(
|
|
228
|
+
'<a href="https://o.example">out <a href="https://i.example">in</a></a>',
|
|
229
|
+
);
|
|
230
|
+
expect(out).toContain("i.example");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("percent-encodes whitespace in a link destination", () => {
|
|
234
|
+
// A bare destination ends at the first whitespace character, so a raw
|
|
235
|
+
// space turned the rest of the URL into a title and a raw newline
|
|
236
|
+
// terminated the link outright.
|
|
237
|
+
expect(render('<a href="https://ex.com/a b">x</a>')).toBe(
|
|
238
|
+
"[x](https://ex.com/a%20b)",
|
|
239
|
+
);
|
|
240
|
+
expect(render('<a href="https://ex.com/a\nb">x</a>')).toBe(
|
|
241
|
+
"[x](https://ex.com/a%0Ab)",
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("escapeLinkHref still balances parens and is a no-op for clean URLs", () => {
|
|
246
|
+
expect(escapeLinkHref("https://e.com/a_(b)")).toBe(
|
|
247
|
+
"https://e.com/a_\\(b\\)",
|
|
248
|
+
);
|
|
249
|
+
expect(escapeLinkHref("https://e.com/plain?a=1&b=2")).toBe(
|
|
250
|
+
"https://e.com/plain?a=1&b=2",
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Outcome tests for the three v0.21.9 outbound-render residuals:
|
|
2
|
+
//
|
|
3
|
+
// 1. `markExpandableQuotes` rewrote `**>` lines INSIDE fenced code blocks,
|
|
4
|
+
// corrupting documented examples into ` >` on the wire.
|
|
5
|
+
// 2. `escapeMarkdown` ran over raw-HTML `plain` nodes and escaped `=`, so
|
|
6
|
+
// `<a href="…">` shipped as `<a href\="…">` — an unreadable attribute.
|
|
7
|
+
// 3. Raw HTML tags reached the wire byte-verbatim with nothing establishing
|
|
8
|
+
// that Telegram's rich parser accepts them.
|
|
9
|
+
//
|
|
10
|
+
// Every assertion here FAILS on the v0.21.9 pipeline. They drive the real
|
|
11
|
+
// `renderOutbound` (`parse` -> `renderSafe`), i.e. exactly what the live send
|
|
12
|
+
// path calls, so they assert the wire OUTCOME, not an internal code path.
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from "vitest";
|
|
15
|
+
import { renderOutbound } from "../../render/rich-render.js";
|
|
16
|
+
import { parse } from "../../render/parse.js";
|
|
17
|
+
import { classifyHtmlTag, hrefOf } from "../../render/html-fold.js";
|
|
18
|
+
|
|
19
|
+
describe("fenced code blocks are immune to the `**>` input repair (defect 1)", () => {
|
|
20
|
+
it("keeps a `**>` line inside a fence byte-identical", () => {
|
|
21
|
+
// The exact corruption: on v0.21.9 this came back as "```md\n > tap…".
|
|
22
|
+
const src = "```md\n**> tap to expand\n> body\n```";
|
|
23
|
+
expect(renderOutbound(src).text).toBe(src);
|
|
24
|
+
const code = parse(src).blocks[0] as { type: string; text: string };
|
|
25
|
+
expect(code.type).toBe("code-block");
|
|
26
|
+
expect(code.text).toBe("**> tap to expand\n> body");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps a `**>` line inside a TILDE fence byte-identical", () => {
|
|
30
|
+
const src = "~~~\n**> quoted\n~~~";
|
|
31
|
+
const code = parse(src).blocks[0] as { type: string; text: string };
|
|
32
|
+
expect(code.type).toBe("code-block");
|
|
33
|
+
expect(code.text).toBe("**> quoted");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("still repairs a `**>` quote OUTSIDE the fence in the same document", () => {
|
|
37
|
+
const src = "```\n**> literal\n```\n\n**> repaired";
|
|
38
|
+
const out = renderOutbound(src).text;
|
|
39
|
+
expect(out).toContain("```\n**> literal\n```");
|
|
40
|
+
expect(out).toContain("> repaired");
|
|
41
|
+
// The repaired quote is a real quote, not a literal `**>` paragraph.
|
|
42
|
+
expect(out.split("\n\n").at(-1)).toBe("> repaired");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does not lose the repair after a CLOSED fence earlier in the body", () => {
|
|
46
|
+
// Guards the fence tracker's close detection: a stuck-open scanner would
|
|
47
|
+
// silently stop repairing every legacy quote after the first fence.
|
|
48
|
+
const src = "```js\nconst a = 1;\n```\n\n**> still repaired";
|
|
49
|
+
expect(renderOutbound(src).text.endsWith("> still repaired")).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("treats an UNCLOSED fence as running to end of document", () => {
|
|
53
|
+
const src = "```\n**> never repaired";
|
|
54
|
+
const code = parse(src).blocks[0] as { type: string; text: string };
|
|
55
|
+
expect(code.type).toBe("code-block");
|
|
56
|
+
expect(code.text).toBe("**> never repaired");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("keeps a fence's `**>` content intact through an inline code round-trip", () => {
|
|
60
|
+
// A wider fence in the source must not change the content bytes.
|
|
61
|
+
const src = "````\n```\n**> nested\n```\n````";
|
|
62
|
+
const code = parse(src).blocks[0] as { type: string; text: string };
|
|
63
|
+
expect(code.text).toBe("```\n**> nested\n```");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("raw HTML links fold to native markdown links (defect 2)", () => {
|
|
68
|
+
it("`<a href>` becomes a working link with no escaped `=`", () => {
|
|
69
|
+
const out = renderOutbound(
|
|
70
|
+
`<a href="https://example.com">label</a>`,
|
|
71
|
+
).text;
|
|
72
|
+
expect(out).toBe("[label](https://example.com)");
|
|
73
|
+
expect(out).not.toContain("\\=");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("an `<a href>` embedded in prose keeps its surroundings", () => {
|
|
77
|
+
const out = renderOutbound(
|
|
78
|
+
`see <a href="https://example.com/a_b">the docs</a> now`,
|
|
79
|
+
).text;
|
|
80
|
+
expect(out).toBe("see [the docs](https://example.com/a_b) now");
|
|
81
|
+
expect(out).not.toContain("\\=");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("accepts a single-quoted href", () => {
|
|
85
|
+
expect(renderOutbound(`<a href='https://e.com'>x</a>`).text).toBe(
|
|
86
|
+
"[x](https://e.com)",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reads an unquoted href value (tokenizer level)", () => {
|
|
91
|
+
// Exercised directly: an UNQUOTED href never reaches the fold through
|
|
92
|
+
// micromark — GFM's autolink-literal extension consumes
|
|
93
|
+
// `https://e.com>x` before the tag can become an mdast `html` node (a
|
|
94
|
+
// documented upstream limitation, not something this layer can reach).
|
|
95
|
+
// The attribute reader still handles the form for the block-HTML path.
|
|
96
|
+
expect(hrefOf(classifyHtmlTag(`<a href=https://e.com>`))).toBe(
|
|
97
|
+
"https://e.com",
|
|
98
|
+
);
|
|
99
|
+
expect(hrefOf(classifyHtmlTag(`<a href="https://e.com/a(b)">`))).toBe(
|
|
100
|
+
"https://e.com/a(b)",
|
|
101
|
+
);
|
|
102
|
+
expect(hrefOf(classifyHtmlTag(`<a name="x">`))).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("an `<a>` with no href degrades to its label, not to broken markup", () => {
|
|
106
|
+
expect(renderOutbound(`<a name="anchor">label</a>`).text).toBe("label");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("a wire-verified allowlist tag keeps its `=` attribute unescaped", () => {
|
|
110
|
+
// `<details open="open">` on v0.21.9 shipped as `open\="open"`.
|
|
111
|
+
const src = `<details open="open"><summary>S</summary>body</details>`;
|
|
112
|
+
expect(renderOutbound(src).text).toBe(src);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("markdown-equivalent HTML tags fold to native constructs (defect 3)", () => {
|
|
117
|
+
it("folds bold / italic / strike / code", () => {
|
|
118
|
+
expect(renderOutbound("<b>bold</b>").text).toBe("**bold**");
|
|
119
|
+
expect(renderOutbound("<strong>bold</strong>").text).toBe("**bold**");
|
|
120
|
+
expect(renderOutbound("<i>it</i>").text).toBe("*it*");
|
|
121
|
+
expect(renderOutbound("<em>it</em>").text).toBe("*it*");
|
|
122
|
+
expect(renderOutbound("<s>gone</s>").text).toBe("~~gone~~");
|
|
123
|
+
expect(renderOutbound("<del>gone</del>").text).toBe("~~gone~~");
|
|
124
|
+
expect(renderOutbound("<code>x_y</code>").text).toBe("`x_y`");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("folds a tag mid-sentence without disturbing the prose", () => {
|
|
128
|
+
expect(renderOutbound("the <b>only</b> way").text).toBe("the **only** way");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("folds nested tags", () => {
|
|
132
|
+
expect(renderOutbound("<b>bold <i>and italic</i></b>").text).toBe(
|
|
133
|
+
"**bold *and italic***",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("handles same-name nesting without closing on the inner tag", () => {
|
|
138
|
+
// Depth tracking matters: matching the FIRST `</b>` would leave a dangling
|
|
139
|
+
// ` c` outside the bold run. The inner bold collapses into the outer one
|
|
140
|
+
// (see the asterisk-soup test below) so every byte lands inside `**…**`.
|
|
141
|
+
expect(renderOutbound("<b>a <b>b</b> c</b>").text).toBe("**a b c**");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("passes the wire-verified allowlist through raw", () => {
|
|
145
|
+
for (const src of [
|
|
146
|
+
"<u>under</u>",
|
|
147
|
+
"H<sub>2</sub>O",
|
|
148
|
+
"x<sup>2</sup>",
|
|
149
|
+
"<aside><cite>note</cite></aside>",
|
|
150
|
+
]) {
|
|
151
|
+
expect(renderOutbound(src).text).toBe(src);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("drops the markup of an unrecognised tag but keeps its content", () => {
|
|
156
|
+
// Never ship a construct we cannot guarantee: an unknown tag risks the
|
|
157
|
+
// `unsupported start tag` 400 whose fallback resends PLAIN text, showing
|
|
158
|
+
// the reader literal markup.
|
|
159
|
+
expect(renderOutbound(`<marquee>scrolling</marquee>`).text).toBe("scrolling");
|
|
160
|
+
expect(renderOutbound(`<div class="x">body</div>`).text).toBe("body");
|
|
161
|
+
expect(renderOutbound(`<span data-x="1">t</span>`).text).toBe("t");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("drops an HTML comment entirely", () => {
|
|
165
|
+
expect(renderOutbound("before <!-- hidden --> after").text).toBe(
|
|
166
|
+
"before after",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps an unmatched open or close marker as escaped literal text", () => {
|
|
171
|
+
// An unmatched marker delimits nothing, so it is PROSE, not markup —
|
|
172
|
+
// deleting it silently mangles the author's sentence. Entity-escaped so
|
|
173
|
+
// the wire still cannot read it as an unsupported start tag.
|
|
174
|
+
expect(renderOutbound("<b>dangling").text).toBe("<b>dangling");
|
|
175
|
+
expect(renderOutbound("dangling</b>").text).toBe("dangling</b>");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("turns `<br>` into a GFM HARD break rather than deleting it", () => {
|
|
179
|
+
// A bare `\n` would be collapsed to a space by Telegram — the renderer
|
|
180
|
+
// runs after `normalizeParagraphBreaks`, so nothing downstream promotes
|
|
181
|
+
// it. Two trailing spaces are the GFM hard-break syntax.
|
|
182
|
+
expect(renderOutbound("one<br>two").text).toBe("one \ntwo");
|
|
183
|
+
expect(renderOutbound("one<br/>two").text).toBe("one \ntwo");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("folds a block-level HTML line (not just inline runs)", () => {
|
|
187
|
+
// A paragraph that is entirely a tag is a mdast `html` BLOCK, a different
|
|
188
|
+
// fold path from an inline `html` phrasing node.
|
|
189
|
+
expect(renderOutbound("para\n\n<b>alone</b>\n\nmore").text).toBe(
|
|
190
|
+
"para\n\n**alone**\n\nmore",
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("escapes prose around a folded tag as prose (no smuggled formatting)", () => {
|
|
195
|
+
// Text between tags is still ordinary prose and must be escaped, or a
|
|
196
|
+
// `[`/`*` in it could re-trigger formatting downstream.
|
|
197
|
+
expect(renderOutbound("<b>a [b] c</b>").text).toBe("**a \\[b\\] c**");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("never emits nested same-kind emphasis (asterisk soup)", () => {
|
|
201
|
+
// `**a **b** c**` renders as literal asterisks on the reader's screen.
|
|
202
|
+
expect(renderOutbound("<b>**already**</b>").text).toBe("**already**");
|
|
203
|
+
expect(renderOutbound("<b>a <b>b</b> c</b>").text).toBe("**a b c**");
|
|
204
|
+
expect(renderOutbound("<i>*x*</i>").text).toBe("*x*");
|
|
205
|
+
// DIFFERENT kinds still nest, as markdown allows.
|
|
206
|
+
expect(renderOutbound("<b><i>bi</i></b>").text).toBe("***bi***");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("never swallows an inner code span's backticks into `<code>`", () => {
|
|
210
|
+
// Folding the inner `code` node's text would DELETE its backticks
|
|
211
|
+
// (`a b c`). Degrade to the children instead — every byte survives.
|
|
212
|
+
expect(renderOutbound("<code>a `b` c</code>").text).toBe("a `b` c");
|
|
213
|
+
expect(renderOutbound("<code><u>x</u></code>").text).toBe("<u>x</u>");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("does not leave a blank-line hole where a dropped block used to be", () => {
|
|
217
|
+
// A block that is nothing but dropped markup must not contribute an empty
|
|
218
|
+
// paragraph and a spurious `\n\n` separator.
|
|
219
|
+
expect(renderOutbound("a\n\n<!-- c -->\n\nb").text).toBe("a\n\nb");
|
|
220
|
+
expect(renderOutbound("a\n\n<div></div>\n\nb").text).toBe("a\n\nb");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("does not treat `a < b` as a tag", () => {
|
|
224
|
+
expect(renderOutbound("if a < b then c").text).toBe("if a < b then c");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("leaves an autolink alone", () => {
|
|
228
|
+
expect(renderOutbound("<https://example.com>").text).toBe(
|
|
229
|
+
"[https://example.com](https://example.com)",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("leaves tags inside a fenced code block completely untouched", () => {
|
|
234
|
+
const src = '```html\n<a href="https://e.com">x</a>\n<b>y</b>\n```';
|
|
235
|
+
expect(renderOutbound(src).text).toBe(src);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("leaves tags inside an inline code span untouched", () => {
|
|
239
|
+
expect(renderOutbound("use `<b>bold</b>` here").text).toBe(
|
|
240
|
+
"use `<b>bold</b>` here",
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("the blank-line-separated expandable shape (majority corpus shape)", () => {
|
|
246
|
+
// `**> header`, a BLANK line, then the `> …` body. Existing coverage only
|
|
247
|
+
// pinned the contiguous and lone-marker variants; this shape is what the
|
|
248
|
+
// corpus actually carries, and nothing locked it in.
|
|
249
|
+
const src = "**> tap to expand\n\n> line one\n> line two";
|
|
250
|
+
|
|
251
|
+
it("emits no `**>` marker anywhere", () => {
|
|
252
|
+
expect(renderOutbound(src).text).not.toContain("**>");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("renders the header and the body as quotes, content intact", () => {
|
|
256
|
+
const out = renderOutbound(src).text;
|
|
257
|
+
expect(out).toBe("> tap to expand\n\n> line one\n> line two");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("marks the header blockquote expandable and leaves the body plain", () => {
|
|
261
|
+
const blocks = parse(src).blocks as Array<{
|
|
262
|
+
type: string;
|
|
263
|
+
expandable?: boolean;
|
|
264
|
+
}>;
|
|
265
|
+
expect(blocks.map((b) => b.type)).toEqual(["blockquote", "blockquote"]);
|
|
266
|
+
expect(blocks[0].expandable).toBe(true);
|
|
267
|
+
expect(blocks[1].expandable).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("survives a body carrying formatting and a link", () => {
|
|
271
|
+
const rich =
|
|
272
|
+
"**> tap to expand\n\n> **bold** body\n> see [docs](https://e.com)";
|
|
273
|
+
const out = renderOutbound(rich).text;
|
|
274
|
+
expect(out).not.toContain("**>");
|
|
275
|
+
expect(out).toContain("> **bold** body");
|
|
276
|
+
expect(out).toContain("> see [docs](https://e.com)");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("is idempotent — re-rendering the output does not re-quote it", () => {
|
|
280
|
+
const once = renderOutbound(src).text;
|
|
281
|
+
expect(renderOutbound(once).text).toBe(once);
|
|
282
|
+
});
|
|
283
|
+
});
|