switchroom 0.16.23 → 0.16.27
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/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2936 -119
- package/dist/cli/switchroom.js +804 -465
- package/dist/host-control/main.js +169 -163
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +4 -4
- package/telegram-plugin/answer-stream.ts +20 -49
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +113 -113
- package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
- package/telegram-plugin/dist/server.js +161 -161
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +427 -680
- package/telegram-plugin/gateway/approval-callback.ts +2 -3
- package/telegram-plugin/gateway/approval-card.test.ts +17 -4
- package/telegram-plugin/gateway/approval-card.ts +16 -6
- package/telegram-plugin/gateway/approvals-commands.ts +18 -24
- package/telegram-plugin/gateway/auth-command.ts +74 -74
- package/telegram-plugin/gateway/auth-line.ts +5 -15
- package/telegram-plugin/gateway/boot-card.ts +20 -22
- package/telegram-plugin/gateway/boot-version.ts +3 -2
- package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
- package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
- package/telegram-plugin/gateway/config-snapshot.ts +9 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
- package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
- package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- package/telegram-plugin/gateway/effort-command.ts +17 -17
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
- package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
- package/telegram-plugin/gateway/gateway.ts +881 -633
- package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
- package/telegram-plugin/gateway/inject-handler.ts +5 -5
- package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
- package/telegram-plugin/gateway/ipc-server.ts +39 -6
- package/telegram-plugin/gateway/linear-activity.ts +16 -14
- package/telegram-plugin/gateway/linear-setup.ts +1 -1
- package/telegram-plugin/gateway/model-command.ts +25 -25
- package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
- package/telegram-plugin/gateway/permission-timeout.ts +76 -0
- package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
- package/telegram-plugin/issues-card.ts +6 -7
- package/telegram-plugin/model-unavailable.ts +8 -12
- package/telegram-plugin/operator-events-history.ts +1 -1
- package/telegram-plugin/operator-events.ts +24 -28
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/pending-work-progress.ts +36 -36
- package/telegram-plugin/permission-title.ts +39 -20
- package/telegram-plugin/pty-partial-handler.ts +5 -13
- package/telegram-plugin/quota-check.ts +5 -5
- package/telegram-plugin/quota-watch.ts +13 -18
- package/telegram-plugin/recent-outbound-dedup.ts +5 -5
- package/telegram-plugin/registry/turns-schema.ts +43 -3
- package/telegram-plugin/retry-api-call.ts +42 -7
- package/telegram-plugin/rich-send.ts +86 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
- package/telegram-plugin/secret-detect/vault-error.ts +29 -22
- package/telegram-plugin/shared/bot-runtime.ts +29 -7
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +49 -98
- package/telegram-plugin/subagent-watcher.ts +2 -2
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
- package/telegram-plugin/tests/answer-stream.test.ts +54 -63
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
- package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bot-api.harness.ts +23 -1
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
- package/telegram-plugin/tests/card-format.test.ts +6 -4
- package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
- package/telegram-plugin/tests/credits-watch.test.ts +5 -5
- package/telegram-plugin/tests/fake-bot-api.ts +58 -4
- package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
- package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
- package/telegram-plugin/tests/operator-events.test.ts +7 -9
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +88 -41
- package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
- package/telegram-plugin/tests/quota-check.test.ts +3 -3
- package/telegram-plugin/tests/quota-watch.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
- package/telegram-plugin/tests/silence-poke.test.ts +75 -112
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
- package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
- package/telegram-plugin/tests/slot-banner.test.ts +9 -6
- package/telegram-plugin/tests/status-accent.test.ts +29 -32
- package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
- package/telegram-plugin/tests/stream-controller.test.ts +63 -52
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
- package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
- package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
- package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
- package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
- package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
- package/telegram-plugin/tests/welcome-text.test.ts +72 -65
- package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
- package/telegram-plugin/text-voice-scrub.ts +8 -11
- package/telegram-plugin/tool-activity-summary.ts +29 -29
- package/telegram-plugin/welcome-text.ts +82 -83
- package/telegram-plugin/worker-activity-feed.ts +2 -3
- package/telegram-plugin/html-sanitize.ts +0 -244
- package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
|
@@ -1,639 +1,151 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for Telegram formatting utilities
|
|
3
|
-
*
|
|
2
|
+
* Tests for the post-#2669 Telegram formatting utilities.
|
|
3
|
+
*
|
|
4
|
+
* The markdown→HTML engine, the HTML sanitizer, and the MarkdownV2 escaper
|
|
5
|
+
* are gone — every outbound message ships raw GFM markdown via
|
|
6
|
+
* `sendRichMessage` / `editMessageText({ markdown })`. What survives in
|
|
7
|
+
* `format.ts` and is covered here:
|
|
8
|
+
*
|
|
9
|
+
* - repairEscapedWhitespace — LLM-side JSON-escape repair (format-agnostic).
|
|
10
|
+
* - escapeMarkdown — inline-special escaper for dynamic card content.
|
|
11
|
+
* - splitMarkdownChunks — code-fence / table-row aware chunker.
|
|
12
|
+
* - RICH_MESSAGE_MAX_CHARS — the 32768 rich-message wire cap.
|
|
4
13
|
*/
|
|
5
14
|
import { describe, test, expect } from 'vitest'
|
|
6
15
|
|
|
7
16
|
// Import from the side-effect-free format module so tests don't trigger
|
|
8
17
|
// server.ts's startup (env load, token check, grammy init).
|
|
9
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
repairEscapedWhitespace,
|
|
20
|
+
escapeMarkdown,
|
|
21
|
+
splitMarkdownChunks,
|
|
22
|
+
RICH_MESSAGE_MAX_CHARS,
|
|
23
|
+
} from '../format.js'
|
|
10
24
|
|
|
11
25
|
// ---------------------------------------------------------------------------
|
|
12
|
-
//
|
|
26
|
+
// escapeMarkdown — narrowed inline-special escaper (#2669)
|
|
13
27
|
// ---------------------------------------------------------------------------
|
|
14
28
|
|
|
15
|
-
describe('
|
|
16
|
-
test('
|
|
17
|
-
expect(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
expect(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
test('converts emoji-leading _📥 Queued as a new task_', () => {
|
|
36
|
-
expect(markdownToHtml('_📥 Queued as a new task_')).toContain('<i>📥 Queued as a new task</i>')
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
test('converts emoji-trailing _steer on the prior task 🔁_', () => {
|
|
40
|
-
expect(markdownToHtml('_steer on the prior task 🔁_')).toContain('<i>steer on the prior task 🔁</i>')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
test('converts both-ends emoji _🔥 hot take 🔥_', () => {
|
|
44
|
-
expect(markdownToHtml('_🔥 hot take 🔥_')).toContain('<i>🔥 hot take 🔥</i>')
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test('does NOT convert snake_case to italic', () => {
|
|
48
|
-
const result = markdownToHtml('my_snake_case_var')
|
|
49
|
-
expect(result).not.toContain('<i>')
|
|
50
|
-
expect(result).toContain('my_snake_case_var')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
test('does NOT convert __double__ underscore to italic', () => {
|
|
54
|
-
const result = markdownToHtml('__double__')
|
|
55
|
-
expect(result).not.toContain('<i>')
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('does NOT convert word-internal underscores', () => {
|
|
59
|
-
const result = markdownToHtml('foo_bar')
|
|
60
|
-
expect(result).not.toContain('<i>')
|
|
61
|
-
expect(result).toContain('foo_bar')
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
test('_..._ and *...* italics coexist correctly', () => {
|
|
65
|
-
const result = markdownToHtml('*asterisk* and _underscore_')
|
|
66
|
-
expect(result).toContain('<i>asterisk</i>')
|
|
67
|
-
expect(result).toContain('<i>underscore</i>')
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
test('converts inline `code` to <code>code</code>', () => {
|
|
71
|
-
expect(markdownToHtml('Use `console.log`')).toContain('<code>console.log</code>')
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test('converts code blocks with language', () => {
|
|
75
|
-
const input = '```typescript\nconst x = 1\n```'
|
|
76
|
-
const result = markdownToHtml(input)
|
|
77
|
-
expect(result).toContain('<pre><code class="language-typescript">')
|
|
78
|
-
expect(result).toContain('const x = 1')
|
|
79
|
-
expect(result).toContain('</code></pre>')
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
test('converts code blocks without language', () => {
|
|
83
|
-
const input = '```\nplain code\n```'
|
|
84
|
-
const result = markdownToHtml(input)
|
|
85
|
-
expect(result).toContain('<pre><code>')
|
|
86
|
-
expect(result).toContain('plain code')
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
test('converts strikethrough ~~text~~ to <s>text</s>', () => {
|
|
90
|
-
expect(markdownToHtml('~~deleted~~')).toContain('<s>deleted</s>')
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
test('converts [text](url) to <a href="url">text</a>', () => {
|
|
94
|
-
const result = markdownToHtml('Click [here](https://example.com)')
|
|
95
|
-
expect(result).toContain('<a href="https://example.com">here</a>')
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
test('escapes HTML entities in plain text', () => {
|
|
99
|
-
const result = markdownToHtml('x < y & z > w')
|
|
100
|
-
expect(result).toContain('<')
|
|
101
|
-
expect(result).toContain('&')
|
|
102
|
-
expect(result).toContain('>')
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
test('does not escape HTML inside code blocks', () => {
|
|
106
|
-
const input = '```html\n<div>test</div>\n```'
|
|
107
|
-
const result = markdownToHtml(input)
|
|
108
|
-
expect(result).toContain('<div>test</div>')
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
test('does not escape HTML inside inline code', () => {
|
|
112
|
-
const result = markdownToHtml('Use `<div>` element')
|
|
113
|
-
expect(result).toContain('<code><div></code>')
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
test('wraps file references in code tags', () => {
|
|
117
|
-
const result = markdownToHtml('Edit server.ts and package.json')
|
|
118
|
-
expect(result).toContain('<code>server.ts</code>')
|
|
119
|
-
expect(result).toContain('<code>package.json</code>')
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
test('does not double-wrap file references already in code', () => {
|
|
123
|
-
const result = markdownToHtml('Edit `server.ts` now')
|
|
124
|
-
// Should have exactly one <code>server.ts</code>, not nested
|
|
125
|
-
const matches = result.match(/<code>server\.ts<\/code>/g)
|
|
126
|
-
expect(matches).not.toBeNull()
|
|
127
|
-
expect(matches!.length).toBe(1)
|
|
128
|
-
// And crucially: NO nested <code><code>...</code></code>
|
|
129
|
-
expect(result).not.toContain('<code><code>')
|
|
130
|
-
expect(result).not.toContain('</code></code>')
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
test('inline code containing asterisks does not get re-matched by italic regex (#415)', () => {
|
|
134
|
-
// Regression for #415: inline-code spans containing `*` (e.g. C
|
|
135
|
-
// pointer syntax) used to be restored from their placeholder BEFORE
|
|
136
|
-
// the italic pass, so the italic regex would see `<code>size_t *p</code>`
|
|
137
|
-
// and try to wrap `p</code>...` in <i>...</i>, producing invalid HTML
|
|
138
|
-
// that Telegram rejected with 400 Bad Request, sending the caller into
|
|
139
|
-
// a `format: text` fallback for the rest of the chunk.
|
|
140
|
-
const result = markdownToHtml('Use `size_t *p` to declare a pointer.')
|
|
141
|
-
expect(result).toContain('<code>size_t *p</code>')
|
|
142
|
-
// No stray <i> wrapping the asterisk — pre-fix the buggy output was
|
|
143
|
-
// `<code>size_t <i>p</code> to declare a pointer.</i>`.
|
|
144
|
-
expect(result).not.toMatch(/<i>[^<]*<\/code>/)
|
|
145
|
-
expect(result).not.toMatch(/<code>[^<]*<i>/)
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
test('inline code containing double-asterisks does not get re-matched by bold regex (#415)', () => {
|
|
149
|
-
const result = markdownToHtml('Pattern is `**glob**` not regex.')
|
|
150
|
-
expect(result).toContain('<code>**glob**</code>')
|
|
151
|
-
// The bold regex must not have wrapped the literal asterisks inside <code>.
|
|
152
|
-
expect(result).not.toMatch(/<b>[^<]*<\/code>/)
|
|
153
|
-
expect(result).not.toMatch(/<code>[^<]*<b>/)
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
test('code block containing asterisks does not get re-matched by italic regex (#415)', () => {
|
|
157
|
-
const input = '```c\nsize_t *p = NULL;\n```'
|
|
158
|
-
const result = markdownToHtml(input)
|
|
159
|
-
expect(result).toContain('size_t *p = NULL;')
|
|
160
|
-
expect(result).not.toMatch(/<i>[^<]*<\/code>/)
|
|
161
|
-
expect(result).not.toMatch(/<i>[^<]*<\/pre>/)
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
test('does not double-wrap when inline code sits alongside prose with file refs', () => {
|
|
165
|
-
// Regression for the user-observed bug: messages that mixed inline code
|
|
166
|
-
// spans (backticks around filenames) with prose produced
|
|
167
|
-
// `<code><code>settings.json</code></code>` in the stored history. The
|
|
168
|
-
// file-reference regex ran AFTER inline-code placeholder restoration and
|
|
169
|
-
// re-wrapped the filename inside the just-restored <code> tag because
|
|
170
|
-
// its negative lookbehind did not exclude `>`.
|
|
171
|
-
const result = markdownToHtml(
|
|
172
|
-
'I mixed raw `<a href="...">` HTML into messages whose `format` defaults ' +
|
|
173
|
-
'to `html` — but the plugin runs a markdown→HTML converter which escapes ' +
|
|
174
|
-
'literal `<` and `>`, so raw tags render as visible text in the rendered ' +
|
|
175
|
-
'`settings.json` output.'
|
|
176
|
-
)
|
|
177
|
-
expect(result).not.toContain('<code><code>')
|
|
178
|
-
expect(result).not.toContain('</code></code>')
|
|
179
|
-
// settings.json, format, html should each appear inside exactly one
|
|
180
|
-
// <code> tag — either from the backtick wrapping or the file-ref regex,
|
|
181
|
-
// but never both.
|
|
182
|
-
const settingsMatches = result.match(/<code>settings\.json<\/code>/g)
|
|
183
|
-
expect(settingsMatches).not.toBeNull()
|
|
184
|
-
expect(settingsMatches!.length).toBe(1)
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
test('file-reference wrap still runs on bare filenames in prose', () => {
|
|
188
|
-
// Confirm the fix doesn't break the normal case: bare filenames in
|
|
189
|
-
// plain prose still get auto-wrapped in <code> tags.
|
|
190
|
-
const result = markdownToHtml('Edit server.ts and then run tsc --noEmit')
|
|
191
|
-
expect(result).toContain('<code>server.ts</code>')
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
test('file-reference wrap does not match filenames adjacent to > (inside tag markup)', () => {
|
|
195
|
-
// A filename that sits right after a `>` (tag close) should not be
|
|
196
|
-
// re-wrapped — it's already inside some structured context.
|
|
197
|
-
const input = '<b>foo.ts</b>'
|
|
198
|
-
const result = markdownToHtml(input)
|
|
199
|
-
// Passes through as Telegram HTML (smart pass-through) — filename is
|
|
200
|
-
// not wrapped in <code> because it's inside a <b>.
|
|
201
|
-
expect(result).toBe(input)
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
test('handles nested bold and italic', () => {
|
|
205
|
-
const result = markdownToHtml('**bold *and italic* text**')
|
|
206
|
-
expect(result).toContain('<b>')
|
|
207
|
-
expect(result).toContain('</b>')
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
test('handles plain text with no formatting', () => {
|
|
211
|
-
const result = markdownToHtml('Just a plain message')
|
|
212
|
-
expect(result).toBe('Just a plain message')
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
test('handles empty string', () => {
|
|
216
|
-
expect(markdownToHtml('')).toBe('')
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
test('preserves multiple paragraphs', () => {
|
|
220
|
-
const result = markdownToHtml('First paragraph\n\nSecond paragraph')
|
|
221
|
-
expect(result).toContain('First paragraph')
|
|
222
|
-
expect(result).toContain('Second paragraph')
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
test('converts ## headings to bold (Telegram has no <h1>)', () => {
|
|
226
|
-
const result = markdownToHtml('## My Heading\n\nbody text')
|
|
227
|
-
expect(result).toContain('<b>My Heading</b>')
|
|
228
|
-
expect(result).not.toContain('## ')
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
test('converts # headings to bold', () => {
|
|
232
|
-
const result = markdownToHtml('# Top heading\n\nbody')
|
|
233
|
-
expect(result).toContain('<b>Top heading</b>')
|
|
234
|
-
expect(result).not.toMatch(/^# /m)
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
test('converts deep ### #### headings to bold without losing content', () => {
|
|
238
|
-
const result = markdownToHtml('### Section\n#### Subsection\nbody')
|
|
239
|
-
expect(result).toContain('<b>Section</b>')
|
|
240
|
-
expect(result).toContain('<b>Subsection</b>')
|
|
241
|
-
expect(result).not.toContain('###')
|
|
242
|
-
expect(result).not.toContain('####')
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
test('does not convert # inside code blocks', () => {
|
|
246
|
-
const input = '```bash\n# this is a comment\n```'
|
|
247
|
-
const result = markdownToHtml(input)
|
|
248
|
-
expect(result).toContain('# this is a comment')
|
|
249
|
-
expect(result).not.toContain('<b># this is a comment</b>')
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
// ─── HTML pass-through (the bug that made <b> tags render as text) ─────
|
|
253
|
-
|
|
254
|
-
test('passes through already-rendered Telegram HTML untouched', () => {
|
|
255
|
-
const input = '<b>Bold heading</b>\n<i>italic body</i>'
|
|
256
|
-
expect(markdownToHtml(input)).toBe(input)
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
test('passes through Telegram HTML with <code> blocks', () => {
|
|
260
|
-
const input = '<b>commit</b> <code>abc123</code>'
|
|
261
|
-
expect(markdownToHtml(input)).toBe(input)
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
test('passes through Telegram HTML with mixed tags and text', () => {
|
|
265
|
-
const input = '<b>What you should see</b>\n👀 immediately, then 🤔 after 2s'
|
|
266
|
-
expect(markdownToHtml(input)).toBe(input)
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
test('escapes when input has unsupported HTML tags (e.g. <div>)', () => {
|
|
270
|
-
const input = '<div>not telegram html</div>'
|
|
271
|
-
const out = markdownToHtml(input)
|
|
272
|
-
// Falls into the markdown path → escapes the angle brackets
|
|
273
|
-
expect(out).toContain('<div>')
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
test('escapes when input is plain markdown without HTML', () => {
|
|
277
|
-
const input = '**bold** text'
|
|
278
|
-
const out = markdownToHtml(input)
|
|
279
|
-
expect(out).toContain('<b>bold</b>')
|
|
280
|
-
})
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
describe('isLikelyTelegramHtml', () => {
|
|
284
|
-
test('returns true for simple <b>', () => {
|
|
285
|
-
expect(isLikelyTelegramHtml('<b>hello</b>')).toBe(true)
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
test('returns true for <code>', () => {
|
|
289
|
-
expect(isLikelyTelegramHtml('use <code>git status</code>')).toBe(true)
|
|
290
|
-
})
|
|
291
|
-
|
|
292
|
-
test('returns true for nested supported tags', () => {
|
|
293
|
-
expect(isLikelyTelegramHtml('<b><i>bold italic</i></b>')).toBe(true)
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
test('returns true for <a href>', () => {
|
|
297
|
-
expect(isLikelyTelegramHtml('see <a href="https://x.com">x</a>')).toBe(true)
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
test('returns false when ANY tag is unsupported', () => {
|
|
301
|
-
expect(isLikelyTelegramHtml('<b>fine</b> but <div>not</div>')).toBe(false)
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
test('returns false for plain text with no tags', () => {
|
|
305
|
-
expect(isLikelyTelegramHtml('just words here')).toBe(false)
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
test('returns false for plain markdown', () => {
|
|
309
|
-
expect(isLikelyTelegramHtml('**bold** and *italic*')).toBe(false)
|
|
310
|
-
})
|
|
311
|
-
|
|
312
|
-
test('returns false for code with angle brackets', () => {
|
|
313
|
-
expect(isLikelyTelegramHtml('the operator <-> means something')).toBe(false)
|
|
314
|
-
})
|
|
315
|
-
|
|
316
|
-
// ─── The bug: HTML tags inside markdown inline code spans ─────────────
|
|
317
|
-
|
|
318
|
-
test('ignores HTML tags inside backtick inline code', () => {
|
|
319
|
-
// The model writes `<b>tag</b>` (showing literal HTML in inline code).
|
|
320
|
-
// The text is markdown, NOT raw HTML — must return false.
|
|
321
|
-
expect(isLikelyTelegramHtml('Use `<b>tag</b>` to make text bold.')).toBe(false)
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
test('ignores HTML tags inside fenced code blocks', () => {
|
|
325
|
-
const input = 'Example:\n```html\n<div>hi</div>\n```\nThat\'s it.'
|
|
326
|
-
expect(isLikelyTelegramHtml(input)).toBe(false)
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
test('returns false when text mixes markdown bold with HTML examples in code', () => {
|
|
330
|
-
// The exact bug pattern from the user-facing screenshot regression
|
|
331
|
-
const input = '**1. Raw HTML rendering** — replies showed `<b>tag</b>` text instead of bold.'
|
|
332
|
-
expect(isLikelyTelegramHtml(input)).toBe(false)
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
test('returns false when text has markdown links', () => {
|
|
336
|
-
expect(isLikelyTelegramHtml('See [docs](https://example.com)')).toBe(false)
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
test('returns false when text has markdown headings', () => {
|
|
340
|
-
expect(isLikelyTelegramHtml('## Section\n\nbody')).toBe(false)
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
test('still returns true for pure HTML even with code spans', () => {
|
|
344
|
-
// Code spans can coexist with real HTML — as long as there are NO
|
|
345
|
-
// markdown bold/link/heading patterns and the tags outside code are
|
|
346
|
-
// all valid Telegram HTML, trust it.
|
|
347
|
-
expect(isLikelyTelegramHtml('<b>commit</b> <code>abc123</code>')).toBe(true)
|
|
348
|
-
})
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
describe('markdownToHtml regression: mixed markdown + raw Telegram HTML', () => {
|
|
352
|
-
// The exact bug pattern from the user-facing screenshot regression: model
|
|
353
|
-
// emits markdown bold AND raw <b>/<a> tags in the same message. The
|
|
354
|
-
// markdown path used to escape every `<` to `<`, so the raw tags
|
|
355
|
-
// rendered as literal text. Now the converter preserves whitelisted
|
|
356
|
-
// Telegram HTML tags through the escape pass.
|
|
357
|
-
|
|
358
|
-
test('preserves embedded <b> when text also has markdown bold', () => {
|
|
359
|
-
const input = '**Pattern worth stealing:** the <b>verification subagent</b> is a validator.'
|
|
360
|
-
const out = markdownToHtml(input)
|
|
361
|
-
expect(out).toContain('<b>Pattern worth stealing:</b>')
|
|
362
|
-
expect(out).toContain('<b>verification subagent</b>')
|
|
363
|
-
expect(out).not.toContain('<b>')
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
test('preserves embedded <a href> when text also has markdown bold', () => {
|
|
367
|
-
const input = '**Sources:** see <a href="https://example.com/x">Example</a> for details.'
|
|
368
|
-
const out = markdownToHtml(input)
|
|
369
|
-
expect(out).toContain('<b>Sources:</b>')
|
|
370
|
-
expect(out).toContain('<a href="https://example.com/x">Example</a>')
|
|
371
|
-
expect(out).not.toContain('<a ')
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
test('preserves embedded <i> when text also has markdown bold', () => {
|
|
375
|
-
const input = '**Rule:** group work by <i>what context it needs</i>.'
|
|
376
|
-
const out = markdownToHtml(input)
|
|
377
|
-
expect(out).toContain('<b>Rule:</b>')
|
|
378
|
-
expect(out).toContain('<i>what context it needs</i>')
|
|
379
|
-
expect(out).not.toContain('<i>')
|
|
380
|
-
})
|
|
381
|
-
|
|
382
|
-
test('preserves multiple embedded tags in one message', () => {
|
|
383
|
-
const input = '**Header**\n- <b>Context</b> matters\n- <i>Speed</i> too\n- See <a href="https://x.com">x</a>'
|
|
384
|
-
const out = markdownToHtml(input)
|
|
385
|
-
expect(out).toContain('<b>Header</b>')
|
|
386
|
-
expect(out).toContain('<b>Context</b>')
|
|
387
|
-
expect(out).toContain('<i>Speed</i>')
|
|
388
|
-
expect(out).toContain('<a href="https://x.com">x</a>')
|
|
389
|
-
})
|
|
390
|
-
|
|
391
|
-
test('still escapes unsupported tags even when whitelisted ones are present', () => {
|
|
392
|
-
const input = '**hi** <b>ok</b> and <div>bad</div>'
|
|
393
|
-
const out = markdownToHtml(input)
|
|
394
|
-
expect(out).toContain('<b>hi</b>')
|
|
395
|
-
expect(out).toContain('<b>ok</b>')
|
|
396
|
-
// <div> is not in the whitelist → escaped
|
|
397
|
-
expect(out).toContain('<div>')
|
|
398
|
-
})
|
|
399
|
-
|
|
400
|
-
test('preserves embedded <code> spans alongside markdown', () => {
|
|
401
|
-
const input = '**Run:** <code>git status</code> first.'
|
|
402
|
-
const out = markdownToHtml(input)
|
|
403
|
-
expect(out).toContain('<b>Run:</b>')
|
|
404
|
-
expect(out).toContain('<code>git status</code>')
|
|
29
|
+
describe('escapeMarkdown', () => {
|
|
30
|
+
test('escapes the inline-formatting specials: \\ ` * _ ~ = [ ] |', () => {
|
|
31
|
+
expect(escapeMarkdown('\\')).toBe('\\\\')
|
|
32
|
+
expect(escapeMarkdown('`')).toBe('\\`')
|
|
33
|
+
expect(escapeMarkdown('*')).toBe('\\*')
|
|
34
|
+
expect(escapeMarkdown('_')).toBe('\\_')
|
|
35
|
+
expect(escapeMarkdown('~')).toBe('\\~')
|
|
36
|
+
expect(escapeMarkdown('=')).toBe('\\=')
|
|
37
|
+
expect(escapeMarkdown('[')).toBe('\\[')
|
|
38
|
+
expect(escapeMarkdown(']')).toBe('\\]')
|
|
39
|
+
expect(escapeMarkdown('|')).toBe('\\|')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('does NOT escape line-start-only / structural chars: . - + # ( ) { } ! >', () => {
|
|
43
|
+
// Escaping these mid-word would litter filenames/versions/URLs with
|
|
44
|
+
// visible backslashes. They are only meaningful at line-start or in
|
|
45
|
+
// link/structure context.
|
|
46
|
+
for (const ch of ['.', '-', '+', '#', '(', ')', '{', '}', '!', '>']) {
|
|
47
|
+
expect(escapeMarkdown(ch)).toBe(ch)
|
|
48
|
+
}
|
|
405
49
|
})
|
|
406
50
|
|
|
407
|
-
test('
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
expect(
|
|
51
|
+
test('leaves a plain filename untouched', () => {
|
|
52
|
+
expect(escapeMarkdown('foo.ts')).toBe('foo.ts')
|
|
53
|
+
expect(escapeMarkdown('v1.2-rc')).toBe('v1.2-rc')
|
|
54
|
+
expect(escapeMarkdown('src/cli/index.ts')).toBe('src/cli/index.ts')
|
|
411
55
|
})
|
|
412
|
-
})
|
|
413
56
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const out = markdownToHtml(input)
|
|
418
|
-
expect(out).toContain('<b>1. Raw HTML rendering</b>')
|
|
419
|
-
expect(out).toContain('<code><b>tag</b></code>')
|
|
420
|
-
expect(out).not.toContain('**1. Raw HTML rendering**')
|
|
57
|
+
test('escapes emphasis markers inside dynamic text so they render literally', () => {
|
|
58
|
+
expect(escapeMarkdown('a_b_c')).toBe('a\\_b\\_c')
|
|
59
|
+
expect(escapeMarkdown('**not bold**')).toBe('\\*\\*not bold\\*\\*')
|
|
421
60
|
})
|
|
422
61
|
|
|
423
|
-
test('
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
expect(out).toContain('<pre><code class="language-html">')
|
|
427
|
-
expect(out).toContain('<div>hi</div>')
|
|
62
|
+
test('escapes the backslash first so it never double-escapes a following special', () => {
|
|
63
|
+
// `\*` in the input is backslash + star → both get escaped exactly once.
|
|
64
|
+
expect(escapeMarkdown('\\*')).toBe('\\\\\\*')
|
|
428
65
|
})
|
|
429
66
|
})
|
|
430
67
|
|
|
431
68
|
// ---------------------------------------------------------------------------
|
|
432
|
-
//
|
|
69
|
+
// splitMarkdownChunks — never bisects a code fence or a table row
|
|
433
70
|
// ---------------------------------------------------------------------------
|
|
434
71
|
|
|
435
|
-
describe('
|
|
436
|
-
test('returns single chunk
|
|
437
|
-
const
|
|
438
|
-
expect(
|
|
439
|
-
})
|
|
440
|
-
|
|
441
|
-
test('splits long text into multiple chunks', () => {
|
|
442
|
-
const longText = 'a'.repeat(5000)
|
|
443
|
-
const chunks = splitHtmlChunks(longText, 2000)
|
|
444
|
-
expect(chunks.length).toBeGreaterThan(1)
|
|
445
|
-
// All chunks should be <= maxLen (plus possible closing tags)
|
|
446
|
-
for (const c of chunks) {
|
|
447
|
-
expect(c.length).toBeLessThanOrEqual(2100) // small margin for closing tags
|
|
448
|
-
}
|
|
449
|
-
})
|
|
450
|
-
|
|
451
|
-
test('preserves open tags across chunk boundaries', () => {
|
|
452
|
-
const html = '<b>' + 'x'.repeat(5000) + '</b>'
|
|
453
|
-
const chunks = splitHtmlChunks(html, 2000)
|
|
454
|
-
expect(chunks.length).toBeGreaterThan(1)
|
|
455
|
-
// First chunk should have closing </b>
|
|
456
|
-
expect(chunks[0]).toContain('</b>')
|
|
457
|
-
// Second chunk should reopen <b>
|
|
458
|
-
expect(chunks[1]).toMatch(/^<b>/)
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
test('prefers splitting at paragraph boundaries', () => {
|
|
462
|
-
const html = 'First paragraph content here' + '\n\n' + 'Second paragraph content here'
|
|
463
|
-
// Set maxLen so it would split somewhere in the middle
|
|
464
|
-
const chunks = splitHtmlChunks(html, 35)
|
|
465
|
-
expect(chunks.length).toBe(2)
|
|
466
|
-
expect(chunks[0]).toContain('First paragraph')
|
|
467
|
-
expect(chunks[1]).toContain('Second paragraph')
|
|
468
|
-
})
|
|
469
|
-
|
|
470
|
-
test('handles nested tags', () => {
|
|
471
|
-
const html = '<b><i>' + 'x'.repeat(5000) + '</i></b>'
|
|
472
|
-
const chunks = splitHtmlChunks(html, 2000)
|
|
473
|
-
expect(chunks.length).toBeGreaterThan(1)
|
|
474
|
-
// First chunk should close both tags
|
|
475
|
-
expect(chunks[0]).toMatch(/<\/i><\/b>$/)
|
|
476
|
-
// Second chunk should reopen both tags
|
|
477
|
-
expect(chunks[1]).toMatch(/^<b><i>/)
|
|
478
|
-
})
|
|
479
|
-
|
|
480
|
-
test('handles empty string', () => {
|
|
481
|
-
expect(splitHtmlChunks('')).toEqual([''])
|
|
482
|
-
})
|
|
483
|
-
|
|
484
|
-
test('respects custom maxLen', () => {
|
|
485
|
-
const text = 'a'.repeat(100)
|
|
486
|
-
const chunks = splitHtmlChunks(text, 30)
|
|
487
|
-
expect(chunks.length).toBeGreaterThanOrEqual(3)
|
|
488
|
-
})
|
|
489
|
-
|
|
490
|
-
test('defaults to 4000 maxLen', () => {
|
|
491
|
-
const text = 'a'.repeat(3999)
|
|
492
|
-
const chunks = splitHtmlChunks(text)
|
|
493
|
-
expect(chunks).toEqual([text])
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
test('does not split inside an HTML entity (&)', () => {
|
|
497
|
-
// Construct text where the natural cut would land inside &
|
|
498
|
-
// Position the entity so that maxLen falls between & and ;
|
|
499
|
-
const filler = 'x'.repeat(20)
|
|
500
|
-
// Cut would be at position 22, mid-entity
|
|
501
|
-
const html = filler + ' & more text after the entity'
|
|
502
|
-
const chunks = splitHtmlChunks(html, 22)
|
|
503
|
-
// The entity should not be broken — we should see the full & in
|
|
504
|
-
// some chunk, never &am or amp;.
|
|
505
|
-
for (const c of chunks) {
|
|
506
|
-
expect(c).not.toMatch(/&am$/)
|
|
507
|
-
expect(c).not.toMatch(/^p;/)
|
|
508
|
-
expect(c).not.toMatch(/^amp;/)
|
|
509
|
-
}
|
|
510
|
-
// Recombined text should equal original (allowing for the chunker's
|
|
511
|
-
// tag-rebalancing trim of leading newlines)
|
|
512
|
-
expect(chunks.join('')).toContain('&')
|
|
513
|
-
})
|
|
514
|
-
|
|
515
|
-
test('does not split inside a numeric HTML entity (💩)', () => {
|
|
516
|
-
const filler = 'a'.repeat(15)
|
|
517
|
-
const html = filler + ' 💩 more'
|
|
518
|
-
const chunks = splitHtmlChunks(html, 20)
|
|
519
|
-
for (const c of chunks) {
|
|
520
|
-
expect(c).not.toMatch(/$/)
|
|
521
|
-
expect(c).not.toMatch(/^4A9;/)
|
|
522
|
-
}
|
|
72
|
+
describe('splitMarkdownChunks', () => {
|
|
73
|
+
test('returns a single chunk when the body fits', () => {
|
|
74
|
+
const text = 'short message'
|
|
75
|
+
expect(splitMarkdownChunks(text, 100)).toEqual([text])
|
|
523
76
|
})
|
|
524
77
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const chunks = splitHtmlChunks(html, 2000)
|
|
530
|
-
expect(chunks.length).toBeGreaterThan(1)
|
|
531
|
-
// Chunk0 must close with the FULL tag name, not a truncated `</tg>`
|
|
532
|
-
expect(chunks[0]).toMatch(/<\/tg-spoiler>$/)
|
|
533
|
-
expect(chunks[0]).not.toMatch(/<\/tg>$/)
|
|
534
|
-
// Chunk1 must reopen with the full tag name
|
|
535
|
-
expect(chunks[1]).toMatch(/^<tg-spoiler>/)
|
|
536
|
-
expect(chunks[1]).not.toMatch(/^<tg>/)
|
|
78
|
+
test('defaults the cap to RICH_MESSAGE_MAX_CHARS', () => {
|
|
79
|
+
const text = 'x'.repeat(RICH_MESSAGE_MAX_CHARS)
|
|
80
|
+
expect(splitMarkdownChunks(text)).toEqual([text])
|
|
81
|
+
expect(splitMarkdownChunks(text + 'y')).toHaveLength(2)
|
|
537
82
|
})
|
|
538
83
|
|
|
539
|
-
test('
|
|
540
|
-
const
|
|
541
|
-
const chunks =
|
|
84
|
+
test('every chunk fits within maxLen', () => {
|
|
85
|
+
const text = Array.from({ length: 50 }, (_, i) => `paragraph ${i} ${'word '.repeat(20)}`).join('\n\n')
|
|
86
|
+
const chunks = splitMarkdownChunks(text, 200)
|
|
542
87
|
expect(chunks.length).toBeGreaterThan(1)
|
|
543
|
-
|
|
544
|
-
expect(chunks[1]).toMatch(/^<tg-emoji/)
|
|
88
|
+
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(200)
|
|
545
89
|
})
|
|
546
90
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
const html = `<a href="${href}">` + 'word '.repeat(1000) + '</a>'
|
|
555
|
-
const chunks = splitHtmlChunks(html, 2000)
|
|
556
|
-
expect(chunks.length).toBeGreaterThan(1)
|
|
557
|
-
// First chunk must close the anchor
|
|
558
|
-
expect(chunks[0]).toMatch(/<\/a>$/)
|
|
559
|
-
// Second chunk must reopen with the FULL href attribute, not bare `<a>`
|
|
560
|
-
expect(chunks[1]).toMatch(new RegExp(`^<a href="${href.replace(/[.?/]/g, '\\$&')}">`))
|
|
561
|
-
expect(chunks[1]).not.toMatch(/^<a>/)
|
|
91
|
+
test('rejoining the chunks reproduces the original content (modulo trimmed boundary newlines)', () => {
|
|
92
|
+
const text = Array.from({ length: 40 }, (_, i) => `line ${i}`).join('\n')
|
|
93
|
+
const chunks = splitMarkdownChunks(text, 50)
|
|
94
|
+
// The chunker strips leading newlines from each subsequent chunk, so
|
|
95
|
+
// join with '\n' and compare the whitespace-collapsed forms.
|
|
96
|
+
const rejoined = chunks.join('\n').replace(/\n+/g, '\n')
|
|
97
|
+
expect(rejoined).toBe(text.replace(/\n+/g, '\n'))
|
|
562
98
|
})
|
|
563
99
|
|
|
564
|
-
test('
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
// ─── Regression: splitter must not cut INSIDE an open tag. Previously,
|
|
573
|
-
// `<a href="..."` followed by a long run of non-space text made the
|
|
574
|
-
// space-fallback pick position 2 (the space inside `<a href=`) and emit
|
|
575
|
-
// a chunk consisting of just `<a`, which Telegram rejects.
|
|
576
|
-
test('does not cut inside an open tag when tag contains the only nearby space', () => {
|
|
577
|
-
const html = '<a href="https://example.com/very/long/url">' + 'y'.repeat(5000) + '</a>'
|
|
578
|
-
const chunks = splitHtmlChunks(html, 2000)
|
|
579
|
-
// No chunk should end mid-tag (e.g. `<a` or `<a href="..`)
|
|
100
|
+
test('never leaves a chunk with an unbalanced ``` fence', () => {
|
|
101
|
+
// A long body with a fenced code block straddling the cap. The chunker
|
|
102
|
+
// must move the whole fence to a single chunk so no chunk has an odd
|
|
103
|
+
// number of fence delimiters (which would swallow the next chunk).
|
|
104
|
+
const fence = '```\n' + 'code line\n'.repeat(20) + '```'
|
|
105
|
+
const text = 'intro paragraph '.repeat(15) + '\n\n' + fence + '\n\noutro paragraph'
|
|
106
|
+
const chunks = splitMarkdownChunks(text, 180)
|
|
580
107
|
for (const c of chunks) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
const withoutTags = c.replace(/<[^>]*>/g, '')
|
|
584
|
-
expect(withoutTags).not.toContain('<')
|
|
108
|
+
const fences = (c.match(/^```/gm) ?? []).length
|
|
109
|
+
expect(fences % 2).toBe(0)
|
|
585
110
|
}
|
|
586
111
|
})
|
|
587
112
|
|
|
588
|
-
test('
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
const
|
|
592
|
-
const chunks =
|
|
593
|
-
//
|
|
113
|
+
test('never bisects a markdown table row', () => {
|
|
114
|
+
const header = '| col a | col b | col c |\n| --- | --- | --- |\n'
|
|
115
|
+
const rows = Array.from({ length: 30 }, (_, i) => `| r${i}a | r${i}b | r${i}c |`).join('\n')
|
|
116
|
+
const text = 'preamble '.repeat(10) + '\n\n' + header + rows
|
|
117
|
+
const chunks = splitMarkdownChunks(text, 160)
|
|
118
|
+
// Every line containing a `|` must be a complete row — i.e. start and
|
|
119
|
+
// end with `|` once trimmed (the chunker cuts at line boundaries).
|
|
594
120
|
for (const c of chunks) {
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
121
|
+
for (const line of c.split('\n')) {
|
|
122
|
+
if (!line.includes('|')) continue
|
|
123
|
+
const t = line.trim()
|
|
124
|
+
if (t.length === 0) continue
|
|
125
|
+
expect(t.startsWith('|')).toBe(true)
|
|
126
|
+
expect(t.endsWith('|')).toBe(true)
|
|
127
|
+
}
|
|
598
128
|
}
|
|
599
129
|
})
|
|
600
|
-
})
|
|
601
130
|
|
|
602
|
-
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
test('wraps .json files', () => {
|
|
612
|
-
expect(markdownToHtml('Check package.json')).toContain('<code>package.json</code>')
|
|
613
|
-
})
|
|
614
|
-
|
|
615
|
-
test('wraps .py files', () => {
|
|
616
|
-
expect(markdownToHtml('Run main.py')).toContain('<code>main.py</code>')
|
|
617
|
-
})
|
|
618
|
-
|
|
619
|
-
test('wraps complex filenames', () => {
|
|
620
|
-
expect(markdownToHtml('Edit my-component.tsx')).toContain('<code>my-component.tsx</code>')
|
|
621
|
-
})
|
|
622
|
-
|
|
623
|
-
test('does not wrap non-file extensions', () => {
|
|
624
|
-
const result = markdownToHtml('This is sentence.ending with a period')
|
|
625
|
-
// "sentence.ending" shouldn't be wrapped since "ending" is not in the ext list
|
|
626
|
-
expect(result).not.toContain('<code>sentence.ending</code>')
|
|
131
|
+
test('emits an oversized indivisible block whole rather than looping forever', () => {
|
|
132
|
+
// A single fenced block larger than maxLen has no safe interior cut —
|
|
133
|
+
// the chunker emits it whole (a louder, debuggable Telegram reject
|
|
134
|
+
// beats an infinite loop).
|
|
135
|
+
const giant = '```\n' + 'x'.repeat(500) + '\n```'
|
|
136
|
+
const chunks = splitMarkdownChunks(giant, 100)
|
|
137
|
+
// It comes out as one (oversized) chunk, not split mid-fence.
|
|
138
|
+
expect(chunks.length).toBe(1)
|
|
139
|
+
expect(chunks[0]).toBe(giant)
|
|
627
140
|
})
|
|
628
141
|
})
|
|
629
142
|
|
|
630
143
|
// ---------------------------------------------------------------------------
|
|
631
|
-
//
|
|
144
|
+
// coalescing logic (pure helpers — unchanged by #2669)
|
|
632
145
|
// ---------------------------------------------------------------------------
|
|
633
146
|
|
|
634
147
|
describe('coalescing logic', () => {
|
|
635
148
|
test('coalesceKey produces unique keys per chat+user', () => {
|
|
636
|
-
// We test the key format directly — the coalescing behavior is integration-level
|
|
637
149
|
const key1 = `chat1:user1`
|
|
638
150
|
const key2 = `chat1:user2`
|
|
639
151
|
const key3 = `chat2:user1`
|
|
@@ -642,7 +154,6 @@ describe('coalescing logic', () => {
|
|
|
642
154
|
})
|
|
643
155
|
|
|
644
156
|
test('messages combine with newline separator', () => {
|
|
645
|
-
// Simulate what the coalescing logic does: join texts with \n
|
|
646
157
|
const messages = ['Hello', 'How are you?', 'One more thing']
|
|
647
158
|
const combined = messages.join('\n')
|
|
648
159
|
expect(combined).toBe('Hello\nHow are you?\nOne more thing')
|
|
@@ -650,26 +161,24 @@ describe('coalescing logic', () => {
|
|
|
650
161
|
|
|
651
162
|
test('single message passes through unchanged', () => {
|
|
652
163
|
const messages = ['Hello']
|
|
653
|
-
|
|
654
|
-
expect(combined).toBe('Hello')
|
|
164
|
+
expect(messages.join('\n')).toBe('Hello')
|
|
655
165
|
})
|
|
656
166
|
|
|
657
167
|
test('empty messages produce empty combined text', () => {
|
|
658
168
|
const messages: string[] = []
|
|
659
|
-
|
|
660
|
-
expect(combined).toBe('')
|
|
169
|
+
expect(messages.join('\n')).toBe('')
|
|
661
170
|
})
|
|
662
171
|
|
|
663
172
|
test('messages with newlines preserve internal structure', () => {
|
|
664
173
|
const messages = ['Line 1\nLine 2', 'Line 3']
|
|
665
|
-
|
|
666
|
-
expect(combined).toBe('Line 1\nLine 2\nLine 3')
|
|
174
|
+
expect(messages.join('\n')).toBe('Line 1\nLine 2\nLine 3')
|
|
667
175
|
})
|
|
668
176
|
})
|
|
669
177
|
|
|
670
178
|
// ---------------------------------------------------------------------------
|
|
671
179
|
// repairEscapedWhitespace — defends against LLM-side JSON escape bungles
|
|
672
180
|
// where real newlines come through as the literal two-char sequence `\n`.
|
|
181
|
+
// Format-agnostic: runs on the raw text BEFORE it reaches the rich path.
|
|
673
182
|
// ---------------------------------------------------------------------------
|
|
674
183
|
|
|
675
184
|
describe('repairEscapedWhitespace', () => {
|
|
@@ -683,68 +192,50 @@ describe('repairEscapedWhitespace', () => {
|
|
|
683
192
|
expect(repairEscapedWhitespace(input)).toBe('Paragraph one.\n\nParagraph two.')
|
|
684
193
|
})
|
|
685
194
|
|
|
686
|
-
test('handles the exact observed bug:
|
|
195
|
+
test('handles the exact observed bug: markdown mixed with literal \\n', () => {
|
|
687
196
|
// Reproduces the actual stream_reply failure: a model produced a message
|
|
688
|
-
// with
|
|
689
|
-
// newlines, and Telegram rendered the `\n` as visible characters.
|
|
690
|
-
|
|
197
|
+
// with markdown and literal `\n` escape sequences instead of real
|
|
198
|
+
// newlines, and Telegram rendered the `\n` as visible characters. The
|
|
199
|
+
// repair runs on raw markdown now (no HTML pass).
|
|
200
|
+
const input =
|
|
201
|
+
'Audit done:\\n\\n**README.md**\\n• Missing `switchroom update`\\n• Missing `switchroom agent grant`'
|
|
691
202
|
const repaired = repairEscapedWhitespace(input)
|
|
692
|
-
expect(repaired).toBe(
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
expect(isLikelyTelegramHtml(repaired)).toBe(true)
|
|
203
|
+
expect(repaired).toBe(
|
|
204
|
+
'Audit done:\n\n**README.md**\n• Missing `switchroom update`\n• Missing `switchroom agent grant`',
|
|
205
|
+
)
|
|
696
206
|
})
|
|
697
207
|
|
|
698
208
|
test('unescapes literal \\n in prose even when real newlines are present (mixed-message fix, #2456)', () => {
|
|
699
|
-
// The old implementation bailed entirely when any real newline was present.
|
|
700
|
-
// The new implementation unescapes literal \\n outside code spans regardless,
|
|
701
|
-
// so a mixed message (real newlines + stray literal \\n in prose) is repaired.
|
|
702
209
|
const input = 'Real newline here\nand a literal \\n escape in prose'
|
|
703
210
|
const out = repairEscapedWhitespace(input)
|
|
704
|
-
// The literal \n in prose becomes a real newline; the existing real newline is preserved.
|
|
705
211
|
expect(out).toBe('Real newline here\nand a literal \n escape in prose')
|
|
706
212
|
})
|
|
707
213
|
|
|
708
214
|
test('preserves literal \\n inside inline code span when mixed with real newlines (#2456)', () => {
|
|
709
|
-
// The structurally safe version: a \\n inside backticks must stay verbatim,
|
|
710
|
-
// even in a message that also has real newlines and literal \\n in prose.
|
|
711
215
|
const input = 'First line\nUse `grep -P \\n` for newlines\nand prose \\n here'
|
|
712
216
|
const out = repairEscapedWhitespace(input)
|
|
713
|
-
// Inline code span preserved verbatim (\\n inside backticks untouched).
|
|
714
217
|
expect(out).toContain('`grep -P \\n`')
|
|
715
|
-
// The literal \\n in prose is unescaped to a real newline.
|
|
716
218
|
expect(out).toContain('and prose \n here')
|
|
717
|
-
// The original real newlines are preserved.
|
|
718
219
|
expect(out).toContain('First line\n')
|
|
719
220
|
})
|
|
720
221
|
|
|
721
222
|
test('preserves literal \\n inside fenced code block (#2456)', () => {
|
|
722
|
-
// A fenced code block containing a literal \\n (e.g. a regex or shell snippet)
|
|
723
|
-
// must not be unescaped.
|
|
724
223
|
const input = 'Prose \\n here\n```bash\necho "line1\\nline2"\n```\nMore \\n prose'
|
|
725
224
|
const out = repairEscapedWhitespace(input)
|
|
726
|
-
// The \\n inside the fenced block stays verbatim.
|
|
727
225
|
expect(out).toContain('```bash\necho "line1\\nline2"\n```')
|
|
728
|
-
// The \\n in prose is unescaped.
|
|
729
226
|
expect(out).toContain('Prose \n here')
|
|
730
227
|
expect(out).toContain('More \n prose')
|
|
731
228
|
})
|
|
732
229
|
|
|
733
230
|
test('pure no-real-newline case still unescapes (backward compat)', () => {
|
|
734
|
-
// Original pre-#2456 case: message with NO real newlines and literal \\n.
|
|
735
231
|
const input = 'Line one\\nLine two\\nLine three'
|
|
736
232
|
expect(repairEscapedWhitespace(input)).toBe('Line one\nLine two\nLine three')
|
|
737
233
|
})
|
|
738
234
|
|
|
739
235
|
test('genuine escaped backslash (\\\\n) stays literal even in mixed-newline message (#2456)', () => {
|
|
740
|
-
// \\\\n in the source is the two-char sequence \\ then n — the user typed
|
|
741
|
-
// a backslash followed by the letter n, NOT a newline. The protect-\\
|
|
742
|
-
// phase must still work inside mixed messages.
|
|
743
236
|
const input = 'Windows path: C:\\\\temp\\\\file.txt\\nnext line\nreal newline too'
|
|
744
237
|
const out = repairEscapedWhitespace(input)
|
|
745
|
-
// The \\n becomes a real newline.
|
|
746
238
|
expect(out).toContain('C:\\temp\\file.txt\nnext line')
|
|
747
|
-
// The real newline is preserved.
|
|
748
239
|
expect(out).toContain('real newline too')
|
|
749
240
|
})
|
|
750
241
|
|
|
@@ -764,41 +255,27 @@ describe('repairEscapedWhitespace', () => {
|
|
|
764
255
|
})
|
|
765
256
|
|
|
766
257
|
test('preserves literal backslash sequences via \\\\', () => {
|
|
767
|
-
// `\\n` in the source is `\\` followed by `n`, which means the user
|
|
768
|
-
// literally wanted a backslash followed by the letter n, NOT a newline.
|
|
769
|
-
// Our order-aware unescape must protect `\\` before touching `\n`.
|
|
770
258
|
const input = 'Windows path: C:\\\\temp\\\\file.txt\\nnext line'
|
|
771
259
|
const out = repairEscapedWhitespace(input)
|
|
772
260
|
expect(out).toBe('Windows path: C:\\temp\\file.txt\nnext line')
|
|
773
261
|
})
|
|
774
262
|
|
|
775
|
-
test('end-to-end
|
|
776
|
-
// Full pipeline
|
|
263
|
+
test('end-to-end: repaired raw markdown is ready for the rich path', () => {
|
|
264
|
+
// Full pipeline now ends at raw GFM markdown — no HTML conversion.
|
|
777
265
|
const broken = '**Bold line**\\n\\n- bullet one\\n- bullet two'
|
|
778
266
|
const repaired = repairEscapedWhitespace(broken)
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
expect(html).toContain('\n\n')
|
|
784
|
-
expect(html).toContain('- bullet one')
|
|
785
|
-
// Literal \n must not survive anywhere.
|
|
786
|
-
expect(html).not.toContain('\\n')
|
|
267
|
+
expect(repaired).toBe('**Bold line**\n\n- bullet one\n- bullet two')
|
|
268
|
+
// Real newlines present; no literal \n survives.
|
|
269
|
+
expect(repaired).toContain('\n\n')
|
|
270
|
+
expect(repaired).not.toContain('\\n')
|
|
787
271
|
})
|
|
788
272
|
|
|
789
273
|
// ── Sentinel-collision safety (reviewer blocker) ─────────────────────────
|
|
790
274
|
|
|
791
275
|
test('does not produce "undefined" when input contains NUL-byte sequences (#2456)', () => {
|
|
792
|
-
// A per-call random nonce makes sentinel collision statistically impossible.
|
|
793
|
-
// Confirm that a message containing NUL bytes (which could match a hardcoded
|
|
794
|
-
// sentinel) is passed through safely rather than emitting literal "undefined".
|
|
795
|
-
// We craft a string that would have matched the OLD hardcoded sentinel \x00REPMASK0\x00
|
|
796
|
-
// to prove it no longer causes corruption.
|
|
797
276
|
const dangerous = 'hello \x00REPMASK0\x00 world \\n end'
|
|
798
277
|
const out = repairEscapedWhitespace(dangerous)
|
|
799
|
-
// Must not produce the string "undefined" in output.
|
|
800
278
|
expect(out).not.toContain('undefined')
|
|
801
|
-
// The \\n in prose must be unescaped.
|
|
802
279
|
expect(out).toContain(' end')
|
|
803
280
|
expect(out).not.toContain('\\n')
|
|
804
281
|
})
|
|
@@ -806,15 +283,9 @@ describe('repairEscapedWhitespace', () => {
|
|
|
806
283
|
// ── Unclosed fenced block (reviewer major) ────────────────────────────────
|
|
807
284
|
|
|
808
285
|
test('handles unclosed fenced block gracefully: \\n in trailing content is still unescaped', () => {
|
|
809
|
-
// An unclosed ``` is not matched by the fenced-block regex (it requires a
|
|
810
|
-
// closing ```). Content after the unmatched opening is treated as prose, so
|
|
811
|
-
// literal \\n there gets unescaped — which is the least-surprising outcome
|
|
812
|
-
// for malformed input (the alternative would be silently swallowing content).
|
|
813
286
|
const input = 'Prose \\n here\n```bash\necho "line1\\nline2"\n'
|
|
814
287
|
const out = repairEscapedWhitespace(input)
|
|
815
|
-
// \\n in prose before the unclosed fence is unescaped.
|
|
816
288
|
expect(out).toContain('Prose \n here')
|
|
817
|
-
// The function must not throw and must return a string.
|
|
818
289
|
expect(typeof out).toBe('string')
|
|
819
290
|
})
|
|
820
291
|
|
|
@@ -829,443 +300,9 @@ describe('repairEscapedWhitespace', () => {
|
|
|
829
300
|
})
|
|
830
301
|
|
|
831
302
|
test('unescapes \\r in prose when real newlines are also present (#2456)', () => {
|
|
832
|
-
// A message that mixes real newlines and a literal \\r escape in prose.
|
|
833
303
|
const input = 'First line\nsome \\r carriage return in prose'
|
|
834
304
|
const out = repairEscapedWhitespace(input)
|
|
835
305
|
expect(out).toContain('some \r carriage return in prose')
|
|
836
306
|
expect(out).toContain('First line\n')
|
|
837
307
|
})
|
|
838
308
|
})
|
|
839
|
-
|
|
840
|
-
// ---------------------------------------------------------------------------
|
|
841
|
-
// sanitizeForTelegram — output invariants enforced pre-send
|
|
842
|
-
// ---------------------------------------------------------------------------
|
|
843
|
-
|
|
844
|
-
describe('sanitizeForTelegram', () => {
|
|
845
|
-
// ── Rule 1: strip ## headings ────────────────────────────────────────────
|
|
846
|
-
|
|
847
|
-
test('strips ## heading and converts to bold', () => {
|
|
848
|
-
const result = sanitizeForTelegram('## My Heading\n\nbody text')
|
|
849
|
-
expect(result).toContain('<b>My Heading</b>')
|
|
850
|
-
expect(result).not.toContain('## ')
|
|
851
|
-
})
|
|
852
|
-
|
|
853
|
-
test('strips ### heading and converts to bold', () => {
|
|
854
|
-
const result = sanitizeForTelegram('### Section\n\nbody')
|
|
855
|
-
expect(result).toContain('<b>Section</b>')
|
|
856
|
-
expect(result).not.toContain('### ')
|
|
857
|
-
})
|
|
858
|
-
|
|
859
|
-
test('strips #### heading and converts to bold', () => {
|
|
860
|
-
const result = sanitizeForTelegram('#### Sub\n\nbody')
|
|
861
|
-
expect(result).toContain('<b>Sub</b>')
|
|
862
|
-
expect(result).not.toContain('#### ')
|
|
863
|
-
})
|
|
864
|
-
|
|
865
|
-
test('strips # (h1) heading and converts to bold', () => {
|
|
866
|
-
const result = sanitizeForTelegram('# Title\n\nbody')
|
|
867
|
-
expect(result).toContain('<b>Title</b>')
|
|
868
|
-
expect(result).not.toContain('# Title')
|
|
869
|
-
})
|
|
870
|
-
|
|
871
|
-
// ── Rule 2: flatten nested bullets ──────────────────────────────────────
|
|
872
|
-
|
|
873
|
-
test('flattens 2-space-indented bullets', () => {
|
|
874
|
-
const result = sanitizeForTelegram('- top\n - sub')
|
|
875
|
-
expect(result).toContain('· sub')
|
|
876
|
-
expect(result).not.toContain(' - sub')
|
|
877
|
-
})
|
|
878
|
-
|
|
879
|
-
test('flattens 4-space-indented bullets', () => {
|
|
880
|
-
const result = sanitizeForTelegram('- top\n - deeply nested')
|
|
881
|
-
expect(result).toContain('· deeply nested')
|
|
882
|
-
expect(result).not.toContain(' - deeply nested')
|
|
883
|
-
})
|
|
884
|
-
|
|
885
|
-
test('flattens tab-indented bullets', () => {
|
|
886
|
-
const result = sanitizeForTelegram('- top\n\t- tabbed sub')
|
|
887
|
-
expect(result).toContain('· tabbed sub')
|
|
888
|
-
expect(result).not.toContain('\t- tabbed sub')
|
|
889
|
-
})
|
|
890
|
-
|
|
891
|
-
test('preserves unindented bullets unchanged', () => {
|
|
892
|
-
const result = sanitizeForTelegram('- item one\n- item two')
|
|
893
|
-
expect(result).toContain('- item one')
|
|
894
|
-
expect(result).toContain('- item two')
|
|
895
|
-
// No middle-dot substitution on top-level bullets
|
|
896
|
-
expect(result).not.toContain('· item one')
|
|
897
|
-
})
|
|
898
|
-
|
|
899
|
-
// ── Rule 3: collapse blank lines ────────────────────────────────────────
|
|
900
|
-
|
|
901
|
-
test('collapses 4 blank lines to 2', () => {
|
|
902
|
-
const result = sanitizeForTelegram('before\n\n\n\nafter')
|
|
903
|
-
expect(result).toBe('before\n\nafter')
|
|
904
|
-
})
|
|
905
|
-
|
|
906
|
-
test('collapses 3 blank lines to 2', () => {
|
|
907
|
-
const result = sanitizeForTelegram('a\n\n\nb')
|
|
908
|
-
expect(result).toBe('a\n\nb')
|
|
909
|
-
})
|
|
910
|
-
|
|
911
|
-
test('leaves exactly 2 blank lines alone', () => {
|
|
912
|
-
const result = sanitizeForTelegram('a\n\nb')
|
|
913
|
-
expect(result).toBe('a\n\nb')
|
|
914
|
-
})
|
|
915
|
-
|
|
916
|
-
// ── Rule 4: trailing whitespace ──────────────────────────────────────────
|
|
917
|
-
|
|
918
|
-
test('strips trailing spaces from lines', () => {
|
|
919
|
-
const result = sanitizeForTelegram('hello \nworld ')
|
|
920
|
-
expect(result).toBe('hello\nworld')
|
|
921
|
-
})
|
|
922
|
-
|
|
923
|
-
test('strips trailing tabs from lines', () => {
|
|
924
|
-
const result = sanitizeForTelegram('hello\t\t\nworld')
|
|
925
|
-
expect(result).toBe('hello\nworld')
|
|
926
|
-
})
|
|
927
|
-
|
|
928
|
-
// ── Rule 5: HTML escape inside code/pre ─────────────────────────────────
|
|
929
|
-
|
|
930
|
-
test('HTML-escapes bare < and > inside <code> block', () => {
|
|
931
|
-
const result = sanitizeForTelegram('<code>a < b && c > d</code>')
|
|
932
|
-
expect(result).toContain('<code>a < b')
|
|
933
|
-
expect(result).toContain('> d</code>')
|
|
934
|
-
})
|
|
935
|
-
|
|
936
|
-
test('HTML-escapes bare & inside <code> block', () => {
|
|
937
|
-
const result = sanitizeForTelegram('<code>foo & bar</code>')
|
|
938
|
-
expect(result).toContain('<code>foo & bar</code>')
|
|
939
|
-
})
|
|
940
|
-
|
|
941
|
-
test('HTML-escapes bare < and > inside <pre> block', () => {
|
|
942
|
-
const result = sanitizeForTelegram('<pre><code>if a < b</code></pre>')
|
|
943
|
-
expect(result).toContain('< b')
|
|
944
|
-
})
|
|
945
|
-
|
|
946
|
-
test('does not double-escape already-escaped & in <code>', () => {
|
|
947
|
-
const result = sanitizeForTelegram('<code>a & b</code>')
|
|
948
|
-
// Must remain single-escaped, not become &amp;
|
|
949
|
-
expect(result).toContain('<code>a & b</code>')
|
|
950
|
-
expect(result).not.toContain('&amp;')
|
|
951
|
-
})
|
|
952
|
-
|
|
953
|
-
test('does not double-escape < in <code>', () => {
|
|
954
|
-
const result = sanitizeForTelegram('<code><div></code>')
|
|
955
|
-
expect(result).toContain('<code><div></code>')
|
|
956
|
-
expect(result).not.toContain('&lt;')
|
|
957
|
-
})
|
|
958
|
-
|
|
959
|
-
test('does not double-escape { numeric entity in <code>', () => {
|
|
960
|
-
const result = sanitizeForTelegram('<code>{ x }</code>')
|
|
961
|
-
expect(result).toContain('{')
|
|
962
|
-
expect(result).not.toContain('&#123;')
|
|
963
|
-
})
|
|
964
|
-
|
|
965
|
-
// ── Code block exclusion from structural rules ───────────────────────────
|
|
966
|
-
|
|
967
|
-
test('does not strip ## heading inside <code> block', () => {
|
|
968
|
-
const result = sanitizeForTelegram('<code>## not a heading</code>')
|
|
969
|
-
// The ## stays; only < > & are touched inside code
|
|
970
|
-
expect(result).toContain('## not a heading')
|
|
971
|
-
})
|
|
972
|
-
|
|
973
|
-
test('does not flatten bullets inside <code> block', () => {
|
|
974
|
-
const result = sanitizeForTelegram('<code> - not flattened</code>')
|
|
975
|
-
// The indented bullet stays verbatim inside code
|
|
976
|
-
expect(result).toContain(' - not flattened')
|
|
977
|
-
})
|
|
978
|
-
|
|
979
|
-
test('does not strip ## heading inside <pre> block', () => {
|
|
980
|
-
const result = sanitizeForTelegram('<pre><code class="language-bash"># comment\n## heading\n</code></pre>')
|
|
981
|
-
expect(result).toContain('## heading')
|
|
982
|
-
})
|
|
983
|
-
|
|
984
|
-
// ── Idempotency ──────────────────────────────────────────────────────────
|
|
985
|
-
|
|
986
|
-
test('is idempotent for heading conversion', () => {
|
|
987
|
-
const once = sanitizeForTelegram('## Heading\n\nbody')
|
|
988
|
-
const twice = sanitizeForTelegram(once)
|
|
989
|
-
expect(twice).toBe(once)
|
|
990
|
-
})
|
|
991
|
-
|
|
992
|
-
test('is idempotent for bullet flattening', () => {
|
|
993
|
-
const once = sanitizeForTelegram('- top\n - sub\n - deep')
|
|
994
|
-
const twice = sanitizeForTelegram(once)
|
|
995
|
-
expect(twice).toBe(once)
|
|
996
|
-
})
|
|
997
|
-
|
|
998
|
-
test('is idempotent for blank-line collapse', () => {
|
|
999
|
-
const once = sanitizeForTelegram('a\n\n\n\nb')
|
|
1000
|
-
const twice = sanitizeForTelegram(once)
|
|
1001
|
-
expect(twice).toBe(once)
|
|
1002
|
-
})
|
|
1003
|
-
|
|
1004
|
-
test('is idempotent for code-block escaping', () => {
|
|
1005
|
-
const once = sanitizeForTelegram('<code>a < b & c > d</code>')
|
|
1006
|
-
const twice = sanitizeForTelegram(once)
|
|
1007
|
-
expect(twice).toBe(once)
|
|
1008
|
-
})
|
|
1009
|
-
|
|
1010
|
-
test('is idempotent for a combined realistic message', () => {
|
|
1011
|
-
const input = [
|
|
1012
|
-
'## Status Report',
|
|
1013
|
-
'',
|
|
1014
|
-
'- top item',
|
|
1015
|
-
' - sub item one',
|
|
1016
|
-
' - sub item two',
|
|
1017
|
-
'',
|
|
1018
|
-
'',
|
|
1019
|
-
'',
|
|
1020
|
-
'Here is some <code>a < b</code> inline code.',
|
|
1021
|
-
].join('\n')
|
|
1022
|
-
const once = sanitizeForTelegram(input)
|
|
1023
|
-
const twice = sanitizeForTelegram(once)
|
|
1024
|
-
expect(twice).toBe(once)
|
|
1025
|
-
})
|
|
1026
|
-
})
|
|
1027
|
-
|
|
1028
|
-
// ---------------------------------------------------------------------------
|
|
1029
|
-
// Markdown table rendering
|
|
1030
|
-
// ---------------------------------------------------------------------------
|
|
1031
|
-
|
|
1032
|
-
describe('markdownToHtml — markdown table rendering', () => {
|
|
1033
|
-
// 2-col 3-row → bullet list
|
|
1034
|
-
test('2-col 3-row renders as bullet list', () => {
|
|
1035
|
-
const input = [
|
|
1036
|
-
'| Name | Value |',
|
|
1037
|
-
'| --- | --- |',
|
|
1038
|
-
'| Alpha | 1 |',
|
|
1039
|
-
'| Beta | 2 |',
|
|
1040
|
-
'| Gamma | 3 |',
|
|
1041
|
-
].join('\n')
|
|
1042
|
-
const result = markdownToHtml(input)
|
|
1043
|
-
// Header line present
|
|
1044
|
-
expect(result).toContain('Name / Value')
|
|
1045
|
-
// Each row is a bullet with <b> first column
|
|
1046
|
-
expect(result).toContain('• <b>Alpha</b>')
|
|
1047
|
-
expect(result).toContain('• <b>Beta</b>')
|
|
1048
|
-
expect(result).toContain('• <b>Gamma</b>')
|
|
1049
|
-
// Values appended after dash
|
|
1050
|
-
expect(result).toContain('— 1')
|
|
1051
|
-
expect(result).toContain('— 2')
|
|
1052
|
-
expect(result).toContain('— 3')
|
|
1053
|
-
// Must NOT contain any raw table markdown pipes
|
|
1054
|
-
expect(result).not.toContain('| --- |')
|
|
1055
|
-
expect(result).not.toContain('<table>')
|
|
1056
|
-
})
|
|
1057
|
-
|
|
1058
|
-
// 3-col 4-row → bullet list (still within ≤3 cols AND ≤6 rows)
|
|
1059
|
-
test('3-col 4-row renders as bullet list', () => {
|
|
1060
|
-
const input = [
|
|
1061
|
-
'| Tool | Status | Notes |',
|
|
1062
|
-
'| ---- | ------ | ----- |',
|
|
1063
|
-
'| bun | ok | fast |',
|
|
1064
|
-
'| tsc | ok | strict |',
|
|
1065
|
-
'| eslint | warn | fixable |',
|
|
1066
|
-
'| vitest | skip | optional |',
|
|
1067
|
-
].join('\n')
|
|
1068
|
-
const result = markdownToHtml(input)
|
|
1069
|
-
expect(result).toContain('• <b>bun</b>')
|
|
1070
|
-
expect(result).toContain('• <b>tsc</b>')
|
|
1071
|
-
// Third column appended too
|
|
1072
|
-
expect(result).toContain('fast')
|
|
1073
|
-
expect(result).toContain('strict')
|
|
1074
|
-
expect(result).not.toContain('| ---- |')
|
|
1075
|
-
})
|
|
1076
|
-
|
|
1077
|
-
// 4-col 3-row → <pre> block (4 cols exceeds limit)
|
|
1078
|
-
test('4-col 3-row renders as <pre> block', () => {
|
|
1079
|
-
const input = [
|
|
1080
|
-
'| A | B | C | D |',
|
|
1081
|
-
'| - | - | - | - |',
|
|
1082
|
-
'| 1 | 2 | 3 | 4 |',
|
|
1083
|
-
'| 5 | 6 | 7 | 8 |',
|
|
1084
|
-
'| 9 | 0 | 1 | 2 |',
|
|
1085
|
-
].join('\n')
|
|
1086
|
-
const result = markdownToHtml(input)
|
|
1087
|
-
expect(result).toContain('<pre>')
|
|
1088
|
-
expect(result).toContain('</pre>')
|
|
1089
|
-
// Column headers should appear in the pre block
|
|
1090
|
-
expect(result).toContain('A')
|
|
1091
|
-
expect(result).toContain('B')
|
|
1092
|
-
// Must not produce a bullet list
|
|
1093
|
-
expect(result).not.toContain('• <b>')
|
|
1094
|
-
})
|
|
1095
|
-
|
|
1096
|
-
// 3-col 8-row → <pre> block (8 rows exceeds ≤6 limit)
|
|
1097
|
-
test('3-col 8-row renders as <pre> block', () => {
|
|
1098
|
-
const rows = Array.from({ length: 8 }, (_, i) => `| Row${i + 1} | X${i} | Y${i} |`)
|
|
1099
|
-
const input = [
|
|
1100
|
-
'| Name | ColX | ColY |',
|
|
1101
|
-
'| ---- | ---- | ---- |',
|
|
1102
|
-
...rows,
|
|
1103
|
-
].join('\n')
|
|
1104
|
-
const result = markdownToHtml(input)
|
|
1105
|
-
expect(result).toContain('<pre>')
|
|
1106
|
-
expect(result).toContain('</pre>')
|
|
1107
|
-
expect(result).not.toContain('• <b>')
|
|
1108
|
-
})
|
|
1109
|
-
|
|
1110
|
-
// Pipe in plain prose is NOT a table
|
|
1111
|
-
test('plain prose with a pipe is not converted to a table', () => {
|
|
1112
|
-
const input = 'Run echo foo | bar to see output'
|
|
1113
|
-
const result = markdownToHtml(input)
|
|
1114
|
-
expect(result).toContain('echo foo | bar')
|
|
1115
|
-
expect(result).not.toContain('• <b>')
|
|
1116
|
-
expect(result).not.toContain('<pre>')
|
|
1117
|
-
})
|
|
1118
|
-
|
|
1119
|
-
// Pipe in code block is not a table
|
|
1120
|
-
test('pipe inside fenced code block is left verbatim', () => {
|
|
1121
|
-
const input = [
|
|
1122
|
-
'```bash',
|
|
1123
|
-
'| Name | Value |',
|
|
1124
|
-
'| --- | --- |',
|
|
1125
|
-
'| foo | bar |',
|
|
1126
|
-
'```',
|
|
1127
|
-
].join('\n')
|
|
1128
|
-
const result = markdownToHtml(input)
|
|
1129
|
-
// Should be inside <pre><code>, not a rendered table
|
|
1130
|
-
expect(result).toContain('<pre>')
|
|
1131
|
-
expect(result).toContain('| Name | Value |')
|
|
1132
|
-
expect(result).not.toContain('• <b>')
|
|
1133
|
-
})
|
|
1134
|
-
|
|
1135
|
-
// Table with empty cells
|
|
1136
|
-
test('table with empty cells is handled gracefully', () => {
|
|
1137
|
-
const input = [
|
|
1138
|
-
'| Key | Value |',
|
|
1139
|
-
'| --- | ----- |',
|
|
1140
|
-
'| present | |',
|
|
1141
|
-
'| | orphan |',
|
|
1142
|
-
].join('\n')
|
|
1143
|
-
const result = markdownToHtml(input)
|
|
1144
|
-
// Should produce output without crashing; empty cells rendered as empty/—
|
|
1145
|
-
expect(result).toContain('• <b>present</b>')
|
|
1146
|
-
// No raw markdown pipes in output
|
|
1147
|
-
expect(result).not.toContain('| --- |')
|
|
1148
|
-
})
|
|
1149
|
-
|
|
1150
|
-
// Table preceded and followed by paragraph text — only the table transforms
|
|
1151
|
-
test('table inside paragraph text: only the table block transforms', () => {
|
|
1152
|
-
const input = [
|
|
1153
|
-
'Before paragraph.',
|
|
1154
|
-
'',
|
|
1155
|
-
'| Name | Score |',
|
|
1156
|
-
'| ---- | ----- |',
|
|
1157
|
-
'| Alice | 95 |',
|
|
1158
|
-
'| Bob | 87 |',
|
|
1159
|
-
'',
|
|
1160
|
-
'After paragraph.',
|
|
1161
|
-
].join('\n')
|
|
1162
|
-
const result = markdownToHtml(input)
|
|
1163
|
-
// Prose preserved
|
|
1164
|
-
expect(result).toContain('Before paragraph.')
|
|
1165
|
-
expect(result).toContain('After paragraph.')
|
|
1166
|
-
// Table converted
|
|
1167
|
-
expect(result).toContain('• <b>Alice</b>')
|
|
1168
|
-
expect(result).toContain('• <b>Bob</b>')
|
|
1169
|
-
// No raw table markdown remains
|
|
1170
|
-
expect(result).not.toContain('| ---- |')
|
|
1171
|
-
})
|
|
1172
|
-
|
|
1173
|
-
// HTML entities in cell content are properly escaped
|
|
1174
|
-
test('cell content with ampersand is safely escaped', () => {
|
|
1175
|
-
const input = [
|
|
1176
|
-
'| Operator | Meaning |',
|
|
1177
|
-
'| -------- | ------- |',
|
|
1178
|
-
'| AND | a & b |',
|
|
1179
|
-
'| OR | x & y |',
|
|
1180
|
-
].join('\n')
|
|
1181
|
-
const result = markdownToHtml(input)
|
|
1182
|
-
// & in cell content must be entity-escaped
|
|
1183
|
-
expect(result).toContain('&')
|
|
1184
|
-
// Output is still a bullet list
|
|
1185
|
-
expect(result).toContain('• <b>AND</b>')
|
|
1186
|
-
expect(result).toContain('• <b>OR</b>')
|
|
1187
|
-
})
|
|
1188
|
-
})
|
|
1189
|
-
|
|
1190
|
-
describe('telegramHtmlToPlainText (HTML parse-reject fallback)', () => {
|
|
1191
|
-
test('strips supported formatting tags, keeps the text', () => {
|
|
1192
|
-
const out = telegramHtmlToPlainText('<b>Bold</b> and <i>italic</i> and <code>x=1</code>')
|
|
1193
|
-
expect(out).toBe('Bold and italic and x=1')
|
|
1194
|
-
})
|
|
1195
|
-
|
|
1196
|
-
test('anchors become "label (href)"', () => {
|
|
1197
|
-
const out = telegramHtmlToPlainText('see <a href="https://example.com/x">the docs</a> now')
|
|
1198
|
-
expect(out).toBe('see the docs (https://example.com/x) now')
|
|
1199
|
-
})
|
|
1200
|
-
|
|
1201
|
-
test('anchor with label equal to href collapses to the bare url', () => {
|
|
1202
|
-
const out = telegramHtmlToPlainText('<a href="https://example.com">https://example.com</a>')
|
|
1203
|
-
expect(out).toBe('https://example.com')
|
|
1204
|
-
})
|
|
1205
|
-
|
|
1206
|
-
test('anchor with empty label yields just the href', () => {
|
|
1207
|
-
expect(telegramHtmlToPlainText('<a href="https://e.com"></a>')).toBe('https://e.com')
|
|
1208
|
-
})
|
|
1209
|
-
|
|
1210
|
-
test('single-quoted and unquoted href forms are handled', () => {
|
|
1211
|
-
expect(telegramHtmlToPlainText("<a href='https://a.co'>A</a>")).toBe('A (https://a.co)')
|
|
1212
|
-
expect(telegramHtmlToPlainText('<a href=https://b.co>B</a>')).toBe('B (https://b.co)')
|
|
1213
|
-
})
|
|
1214
|
-
|
|
1215
|
-
test('decodes the standard HTML entities (no double-decode of the result)', () => {
|
|
1216
|
-
const out = telegramHtmlToPlainText('a & b <tag> "q" 's' 5 €')
|
|
1217
|
-
expect(out).toBe('a & b <tag> "q" \'s\' 5 €')
|
|
1218
|
-
})
|
|
1219
|
-
|
|
1220
|
-
test('numeric + hex char references decode', () => {
|
|
1221
|
-
expect(telegramHtmlToPlainText('→ →')).toBe('→ →')
|
|
1222
|
-
})
|
|
1223
|
-
|
|
1224
|
-
test('out-of-range / malformed char refs are left literal', () => {
|
|
1225
|
-
expect(telegramHtmlToPlainText('� � &#xZZ;')).toBe('� � &#xZZ;')
|
|
1226
|
-
})
|
|
1227
|
-
|
|
1228
|
-
test('block/break boundaries become newlines', () => {
|
|
1229
|
-
const out = telegramHtmlToPlainText('one<br>two<br/>three</p>four</blockquote>five')
|
|
1230
|
-
expect(out).toBe('one\ntwo\nthree\nfour\nfive')
|
|
1231
|
-
})
|
|
1232
|
-
|
|
1233
|
-
test('unsupported / malformed tags (the actual reject cause) are stripped, not escaped', () => {
|
|
1234
|
-
// A markdown→HTML slip that emitted an unsupported tag is exactly
|
|
1235
|
-
// what triggers Telegram's 400; the fallback must yield clean text.
|
|
1236
|
-
const out = telegramHtmlToPlainText('<h2>Title</h2><span class=x>body </span><unknowntag>tail')
|
|
1237
|
-
expect(out).toBe('Title\nbody tail')
|
|
1238
|
-
})
|
|
1239
|
-
|
|
1240
|
-
test('result is literal (parse_mode unset) — no re-escaping of < > &', () => {
|
|
1241
|
-
// We resend with parse_mode UNSET, so the output must be the raw
|
|
1242
|
-
// characters, not HTML entities.
|
|
1243
|
-
const out = telegramHtmlToPlainText('a < b && c > d')
|
|
1244
|
-
expect(out).toBe('a < b && c > d')
|
|
1245
|
-
expect(out).not.toContain('<')
|
|
1246
|
-
expect(out).not.toContain('&')
|
|
1247
|
-
})
|
|
1248
|
-
|
|
1249
|
-
test('collapses 3+ blank lines and trims trailing line whitespace', () => {
|
|
1250
|
-
const out = telegramHtmlToPlainText('a \n\n\n\n\nb')
|
|
1251
|
-
expect(out).toBe('a\n\nb')
|
|
1252
|
-
})
|
|
1253
|
-
|
|
1254
|
-
test('nested formatting inside an anchor label is flattened', () => {
|
|
1255
|
-
const out = telegramHtmlToPlainText('<a href="https://x.io"><b>Big</b> link</a>')
|
|
1256
|
-
expect(out).toBe('Big link (https://x.io)')
|
|
1257
|
-
})
|
|
1258
|
-
|
|
1259
|
-
test('empty / whitespace input is safe', () => {
|
|
1260
|
-
expect(telegramHtmlToPlainText('')).toBe('')
|
|
1261
|
-
expect(telegramHtmlToPlainText(' \n ')).toBe('')
|
|
1262
|
-
})
|
|
1263
|
-
|
|
1264
|
-
test('pure-markup chunk collapses to empty (gateway substitutes a placeholder)', () => {
|
|
1265
|
-
// Documents the trigger for the empty-string guard in
|
|
1266
|
-
// gateway.ts:sendChunkPlainText — a chunk with no text content
|
|
1267
|
-
// strips to '', so the send path must substitute rather than
|
|
1268
|
-
// post an empty message (Telegram 400 "message text is empty").
|
|
1269
|
-
expect(telegramHtmlToPlainText('<b></b><i></i><br><span></span>')).toBe('')
|
|
1270
|
-
})
|
|
1271
|
-
})
|