1import { parseLinks, LinkifyResult } from "./linkify";
2
3interface TestCase {
4 name: string;
5 input: string;
6 expected: LinkifyResult[];
7}
8
9const testCases: TestCase[] = [
10 {
11 name: "plain text with no URLs",
12 input: "Hello world",
13 expected: [{ type: "text", content: "Hello world" }],
14 },
15 {
16 name: "simple http URL",
17 input: "Check out http://example.com for more",
18 expected: [
19 { type: "text", content: "Check out " },
20 { type: "link", content: "http://example.com", href: "http://example.com" },
21 { type: "text", content: " for more" },
22 ],
23 },
24 {
25 name: "simple https URL",
26 input: "Visit https://example.com today",
27 expected: [
28 { type: "text", content: "Visit " },
29 { type: "link", content: "https://example.com", href: "https://example.com" },
30 { type: "text", content: " today" },
31 ],
32 },
33 {
34 name: "URL with path",
35 input: "See https://example.com/path/to/page for details",
36 expected: [
37 { type: "text", content: "See " },
38 {
39 type: "link",
40 content: "https://example.com/path/to/page",
41 href: "https://example.com/path/to/page",
42 },
43 { type: "text", content: " for details" },
44 ],
45 },
46 {
47 name: "URL with query parameters",
48 input: "Link: https://example.com/search?q=test&page=1",
49 expected: [
50 { type: "text", content: "Link: " },
51 {
52 type: "link",
53 content: "https://example.com/search?q=test&page=1",
54 href: "https://example.com/search?q=test&page=1",
55 },
56 ],
57 },
58 {
59 name: "URL with port",
60 input: "Server at https://localhost:8080/api",
61 expected: [
62 { type: "text", content: "Server at " },
63 {
64 type: "link",
65 content: "https://localhost:8080/api",
66 href: "https://localhost:8080/api",
67 },
68 ],
69 },
70 {
71 name: "URL followed by period (sentence end)",
72 input: "Check https://example.com.",
73 expected: [
74 { type: "text", content: "Check " },
75 { type: "link", content: "https://example.com", href: "https://example.com" },
76 { type: "text", content: "." },
77 ],
78 },
79 {
80 name: "URL followed by comma",
81 input: "Visit https://example.com, then continue",
82 expected: [
83 { type: "text", content: "Visit " },
84 { type: "link", content: "https://example.com", href: "https://example.com" },
85 { type: "text", content: ", then continue" },
86 ],
87 },
88 {
89 name: "URL followed by exclamation",
90 input: "Wow https://example.com!",
91 expected: [
92 { type: "text", content: "Wow " },
93 { type: "link", content: "https://example.com", href: "https://example.com" },
94 { type: "text", content: "!" },
95 ],
96 },
97 {
98 name: "URL followed by question mark",
99 input: "Have you seen https://example.com?",
100 expected: [
101 { type: "text", content: "Have you seen " },
102 { type: "link", content: "https://example.com", href: "https://example.com" },
103 { type: "text", content: "?" },
104 ],
105 },
106 {
107 name: "multiple URLs",
108 input: "Try https://a.com and https://b.com too",
109 expected: [
110 { type: "text", content: "Try " },
111 { type: "link", content: "https://a.com", href: "https://a.com" },
112 { type: "text", content: " and " },
113 { type: "link", content: "https://b.com", href: "https://b.com" },
114 { type: "text", content: " too" },
115 ],
116 },
117 {
118 name: "URL at start of text",
119 input: "https://example.com is the site",
120 expected: [
121 { type: "link", content: "https://example.com", href: "https://example.com" },
122 { type: "text", content: " is the site" },
123 ],
124 },
125 {
126 name: "URL at end of text",
127 input: "The site is https://example.com",
128 expected: [
129 { type: "text", content: "The site is " },
130 { type: "link", content: "https://example.com", href: "https://example.com" },
131 ],
132 },
133 {
134 name: "URL only",
135 input: "https://example.com",
136 expected: [{ type: "link", content: "https://example.com", href: "https://example.com" }],
137 },
138 {
139 name: "empty string",
140 input: "",
141 expected: [],
142 },
143 {
144 name: "URL with fragment",
145 input: "See https://example.com/page#section for more",
146 expected: [
147 { type: "text", content: "See " },
148 {
149 type: "link",
150 content: "https://example.com/page#section",
151 href: "https://example.com/page#section",
152 },
153 { type: "text", content: " for more" },
154 ],
155 },
156 {
157 name: "URL in parentheses - should not include closing paren",
158 input: "(see https://example.com)",
159 expected: [
160 { type: "text", content: "(see " },
161 { type: "link", content: "https://example.com", href: "https://example.com" },
162 { type: "text", content: ")" },
163 ],
164 },
165 {
166 name: "URL with trailing colon and more text",
167 input: "URL: https://example.com: that was it",
168 expected: [
169 { type: "text", content: "URL: " },
170 { type: "link", content: "https://example.com", href: "https://example.com" },
171 { type: "text", content: ": that was it" },
172 ],
173 },
174 {
175 name: "does not match ftp URLs",
176 input: "Not matched: ftp://example.com",
177 expected: [{ type: "text", content: "Not matched: ftp://example.com" }],
178 },
179 {
180 name: "does not match mailto",
181 input: "Email: mailto:test@example.com",
182 expected: [{ type: "text", content: "Email: mailto:test@example.com" }],
183 },
184 {
185 name: "URL with underscores and dashes",
186 input: "Go to https://my-site.example.com/some_page",
187 expected: [
188 { type: "text", content: "Go to " },
189 {
190 type: "link",
191 content: "https://my-site.example.com/some_page",
192 href: "https://my-site.example.com/some_page",
193 },
194 ],
195 },
196 {
197 name: "URL followed by semicolon",
198 input: "First https://a.com; then more",
199 expected: [
200 { type: "text", content: "First " },
201 { type: "link", content: "https://a.com", href: "https://a.com" },
202 { type: "text", content: "; then more" },
203 ],
204 },
205 {
206 name: "newlines around URL",
207 input: "Line 1\nhttps://example.com\nLine 3",
208 expected: [
209 { type: "text", content: "Line 1\n" },
210 { type: "link", content: "https://example.com", href: "https://example.com" },
211 { type: "text", content: "\nLine 3" },
212 ],
213 },
214 {
215 name: "XSS attempt in URL - javascript protocol not matched",
216 input: "javascript:alert('xss')",
217 expected: [{ type: "text", content: "javascript:alert('xss')" }],
218 },
219 {
220 name: "XSS attempt - script tags in text preserved as text",
221 input: "<script>alert('xss')</script> https://example.com",
222 expected: [
223 { type: "text", content: "<script>alert('xss')</script> " },
224 { type: "link", content: "https://example.com", href: "https://example.com" },
225 ],
226 },
227 {
228 name: "URL not matched inside angle brackets",
229 input: "See <https://example.com> for more",
230 expected: [
231 { type: "text", content: "See <" },
232 { type: "link", content: "https://example.com", href: "https://example.com" },
233 { type: "text", content: "> for more" },
234 ],
235 },
236 {
237 name: "URL in markdown bold - should not include asterisks",
238 input: "Download here: **https://example.com/file.vsix**",
239 expected: [
240 { type: "text", content: "Download here: **" },
241 {
242 type: "link",
243 content: "https://example.com/file.vsix",
244 href: "https://example.com/file.vsix",
245 },
246 { type: "text", content: "**" },
247 ],
248 },
249];
250
251function deepEqual(a: unknown, b: unknown): boolean {
252 if (a === b) return true;
253 if (typeof a !== typeof b) return false;
254 if (a === null || b === null) return a === b;
255 if (typeof a !== "object") return false;
256
257 if (Array.isArray(a) && Array.isArray(b)) {
258 if (a.length !== b.length) return false;
259 return a.every((item, i) => deepEqual(item, b[i]));
260 }
261
262 if (Array.isArray(a) || Array.isArray(b)) return false;
263
264 const aObj = a as Record<string, unknown>;
265 const bObj = b as Record<string, unknown>;
266 const aKeys = Object.keys(aObj);
267 const bKeys = Object.keys(bObj);
268
269 if (aKeys.length !== bKeys.length) return false;
270 return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
271}
272
273export function runTests(): { passed: number; failed: number; failures: string[] } {
274 let passed = 0;
275 let failed = 0;
276 const failures: string[] = [];
277
278 for (const tc of testCases) {
279 const result = parseLinks(tc.input);
280 if (deepEqual(result, tc.expected)) {
281 passed++;
282 } else {
283 failed++;
284 failures.push(
285 `FAIL: ${tc.name}\n Input: ${JSON.stringify(tc.input)}\n Expected: ${JSON.stringify(tc.expected)}\n Got: ${JSON.stringify(result)}`,
286 );
287 }
288 }
289
290 return { passed, failed, failures };
291}
292
293// Export test cases for use in browser
294export { testCases };