linkify.test.ts

  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 };