From 9600b169ccdb7dda16d8d451f9227ce77274a009 Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 13 Oct 2025 10:40:56 +0700 Subject: [PATCH] TF-4054 Add comprehensive tests for `wrapPlainTextLinks` --- core/lib/utils/html/html_utils.dart | 39 +++++-- core/test/utils/html_utils_test.dart | 147 +++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 7 deletions(-) diff --git a/core/lib/utils/html/html_utils.dart b/core/lib/utils/html/html_utils.dart index 8ea5bf035..546b04fef 100644 --- a/core/lib/utils/html/html_utils.dart +++ b/core/lib/utils/html/html_utils.dart @@ -723,7 +723,12 @@ class HtmlUtils { if (container == null) return htmlString; - final urlRegex = RegExp(r'''(?:(?:https?:\/\/)|(?:www\.))[^\s<]+[^<.,:;\"')\]\s!?]'''); + final urlRegex = RegExp( + r'''(?:(?:https?:\/\/)|(?:ftp:\/\/)|(?:mailto:)|(?:file:\/\/)|(?:www\.))(?!\.)(?!.*\.\.)([^\s<]+[^<.,:;\"\'\)\]\s!?])''', + caseSensitive: false, + multiLine: true, + dotAll: true, + ); _processNode(container, urlRegex); @@ -735,7 +740,16 @@ class HtmlUtils { } static final _skipTags = { - 'a', 'img', 'video', 'audio', 'source', 'link', 'script', 'iframe' + 'a', + 'img', + 'video', + 'audio', + 'source', + 'link', + 'script', + 'iframe', + 'code', + 'pre', }; static void _processNode(dom.Node node, RegExp urlRegex) { @@ -763,11 +777,22 @@ class HtmlUtils { nodes.add(dom.Text(text.substring(lastIndex, start))); } - final href = url.startsWith('http') ? url : 'https://$url'; - final link = dom.Element.tag('a') - ..attributes['href'] = href - ..text = url; - nodes.add(link); + // ignore unsafe URLs + if (url.toLowerCase().startsWith('javascript:') || + url.toLowerCase().startsWith('data:')) { + nodes.add(dom.Text(url)); + } else { + // Normalize href + final href = url.startsWith(RegExp(r'https?|ftp|mailto|file')) + ? url + : 'https://$url'; + + final link = dom.Element.tag('a') + ..attributes['href'] = href + ..text = url; + + nodes.add(link); + } lastIndex = match.end; } diff --git a/core/test/utils/html_utils_test.dart b/core/test/utils/html_utils_test.dart index 180cee2cd..58d3353d7 100644 --- a/core/test/utils/html_utils_test.dart +++ b/core/test/utils/html_utils_test.dart @@ -698,5 +698,152 @@ void main() { reason: 'There must be no tag inside href or src attributes', ); }); + + test('Protocol variations: http/https/www', () { + const html = 'A https://example.com B http://foo.bar C www.site.com'; + final result = HtmlUtils.wrapPlainTextLinks(html); + + expect(result, contains('https://example.com')); + expect(result, contains('http://foo.bar')); + expect(result, contains('www.site.com')); + }); + + test('Protocol variations: ftp, mailto, file', () { + const html = + 'ftp://files.example.com mailto:me@example.com file:///Users/me/readme.txt'; + final out = HtmlUtils.wrapPlainTextLinks(html); + + expect( + out, + contains('ftp://files.example.com'), + ); + + expect( + out, + contains('mailto:me@example.com'), + ); + + expect( + out, + contains( + 'file:///Users/me/readme.txt', + ), + ); + }); + + test('Fragment + query: keep the same href, do not double https://', () { + const html = + 'View https://example.com/page?query=1#section and https://a.b/c#frag'; + final out = HtmlUtils.wrapPlainTextLinks(html); + + expect( + out, + contains( + 'https://example.com/page?query=1#section', + ), + ); + expect( + out, + contains('https://a.b/c#frag'), + ); + }); + + test('Do not wrap in and
', () {
+      const html = '
https://example.com
www.site.com'; + final out = HtmlUtils.wrapPlainTextLinks(html); + + expect(out, contains('
https://example.com
')); + expect(out, contains('www.site.com')); + expect(out, isNot(contains('
'));
+    });
+
+    test('IP addresses: http + www.*', () {
+      const html = 'http://192.168.1.1 www.127.0.0.1';
+      final out = HtmlUtils.wrapPlainTextLinks(html);
+
+      expect(out, contains('http://192.168.1.1'));
+      expect(out, contains('www.127.0.0.1'));
+    });
+
+    test('URLs in blockquote/list/table are wrapped', () {
+      const html = '''
+      
https://example.com
+
  • www.site.com
+
https://abc.com
+ '''; + final out = HtmlUtils.wrapPlainTextLinks(html).replaceAll('\n', ''); + + expect( + out, + contains( + '
https://example.com
', + ), + ); + expect( + out, + contains('
  • www.site.com
  • '), + ); + expect( + out, + contains('https://abc.com'), + ); + }); + + test('Malformed URLs: no wrap', () { + const html = 'Wrong: http//example and www..com'; + final out = HtmlUtils.wrapPlainTextLinks(html); + + expect(out, isNot(contains('www.example1.comwww.example2.com', + ), + ); + expect(out.indexOf(' (skip parent tag)', () { + const html = + 'https://example.com vĂ  www.site.com'; + final out = HtmlUtils.wrapPlainTextLinks(html); + + expect( + out, + contains( + 'https://example.com', + ), + ); + expect( + out, + contains('www.site.com'), + ); + }); }); } \ No newline at end of file