Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 59 additions & 19 deletions Flow.Launcher.Test/Plugins/UrlPluginTest.cs
Original file line number Diff line number Diff line change
@@ -1,33 +1,73 @@
using NUnit.Framework;

Check warning on line 1 in Flow.Launcher.Test/Plugins/UrlPluginTest.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`NUnit` is not a recognized word. (unrecognized-spelling)
Comment thread
Jack251970 marked this conversation as resolved.
using NUnit.Framework.Legacy;
using Flow.Launcher.Plugin.Url;
using System.Reflection;

namespace Flow.Launcher.Test.Plugins
{
[TestFixture]
public class UrlPluginTest
{
[Test]
public void URLMatchTest()
private static Main plugin;

[OneTimeSetUp]
public void OneTimeSetup()
{
var plugin = new Main();
ClassicAssert.IsTrue(plugin.IsURL("http://www.google.com"));
ClassicAssert.IsTrue(plugin.IsURL("https://www.google.com"));
ClassicAssert.IsTrue(plugin.IsURL("http://google.com"));
ClassicAssert.IsTrue(plugin.IsURL("www.google.com"));
ClassicAssert.IsTrue(plugin.IsURL("google.com"));
ClassicAssert.IsTrue(plugin.IsURL("http://localhost"));
ClassicAssert.IsTrue(plugin.IsURL("https://localhost"));
ClassicAssert.IsTrue(plugin.IsURL("http://localhost:80"));
ClassicAssert.IsTrue(plugin.IsURL("https://localhost:80"));
ClassicAssert.IsTrue(plugin.IsURL("http://110.10.10.10"));
ClassicAssert.IsTrue(plugin.IsURL("110.10.10.10"));
ClassicAssert.IsTrue(plugin.IsURL("ftp://110.10.10.10"));
var settingsField = typeof(Main).GetField("Settings", BindingFlags.NonPublic | BindingFlags.Static);
settingsField?.SetValue(null, new Settings());
Comment thread
Jack251970 marked this conversation as resolved.
Outdated

plugin = new Main();
}

[TestCase("http://www.google.com")]
[TestCase("https://www.google.com")]
[TestCase("http://google.com")]
[TestCase("ftp://google.com")]
[TestCase("www.google.com")]

Check warning on line 25 in Flow.Launcher.Test/Plugins/UrlPluginTest.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`google` is not a recognized word. (unrecognized-spelling)
[TestCase("google.com")]

Check warning on line 26 in Flow.Launcher.Test/Plugins/UrlPluginTest.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`google` is not a recognized word. (unrecognized-spelling)

Check warning on line 26 in Flow.Launcher.Test/Plugins/UrlPluginTest.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`google` is not a recognized word. (unrecognized-spelling)
[TestCase("http://localhost")]
[TestCase("https://localhost")]
[TestCase("http://localhost:80")]
[TestCase("https://localhost:80")]
[TestCase("localhost")]
[TestCase("localhost:8080")]
[TestCase("http://110.10.10.10")]
[TestCase("110.10.10.10")]
[TestCase("110.10.10.10:8080")]
[TestCase("192.168.1.1")]
[TestCase("192.168.1.1:3000")]
[TestCase("ftp://110.10.10.10")]
[TestCase("[2001:db8::1]")]
[TestCase("[2001:db8::1]:8080")]
[TestCase("http://[2001:db8::1]")]
[TestCase("https://[2001:db8::1]:8080")]
[TestCase("[::1]")]
[TestCase("[::1]:8080")]
[TestCase("2001:db8::1")]
[TestCase("fe80:1:2::3:4")]
Comment thread
VictoriousRaptor marked this conversation as resolved.
[TestCase("::1")]
Comment thread
VictoriousRaptor marked this conversation as resolved.
[TestCase("HTTP://EXAMPLE.COM")]
[TestCase("HTTPS://EXAMPLE.COM")]
[TestCase("EXAMPLE.COM")]
[TestCase("LOCALHOST")]
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test cases include uppercase variants (HTTP://EXAMPLE.COM, HTTPS://EXAMPLE.COM, EXAMPLE.COM, LOCALHOST) which is good. However, it would be beneficial to also test mixed case scenarios and ensure that the validation and URL opening work correctly regardless of case. The code uses StringComparison.OrdinalIgnoreCase which should handle this, but explicit test coverage would be valuable.

Suggested change
[TestCase("LOCALHOST")]
[TestCase("LOCALHOST")]
[TestCase("Http://Example.Com")]
[TestCase("hTTps://ExAmPlE.CoM")]
[TestCase("Example.Com")]
[TestCase("LocalHost")]

Copilot uses AI. Check for mistakes.
public void WhenValidUrlThenIsUrlReturnsTrue(string url)
{
Assert.That(plugin.IsURL(url), Is.True);
}
Comment on lines +62 to +65
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test cases should include scenarios with query parameters and fragments to ensure the URL validation handles them correctly. For example, add test cases like "192.168.1.1?query=value", "localhost:8080?test=123", "example.com#fragment", etc. These are common URL patterns that should be supported.

Copilot uses AI. Check for mistakes.
Comment thread
VictoriousRaptor marked this conversation as resolved.

ClassicAssert.IsFalse(plugin.IsURL("wwww"));
ClassicAssert.IsFalse(plugin.IsURL("wwww.c"));
ClassicAssert.IsFalse(plugin.IsURL("wwww.c"));
[TestCase("wwww")]
[TestCase("wwww.c")]
[TestCase("not a url")]
[TestCase("just text")]
[TestCase("http://")]
[TestCase("://example.com")]
[TestCase("0.0.0.0")] // Pattern excludes 0.0.0.0
Comment thread
Jack251970 marked this conversation as resolved.
[TestCase("256.1.1.1")] // Invalid IPv4
[TestCase("example")] // No TLD
[TestCase(".com")]
[TestCase("http://.com")]
public void WhenInvalidUrlThenIsUrlReturnsFalse(string url)
{
Assert.That(plugin.IsURL(url), Is.False);
}
Comment on lines +79 to 82
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test cases for invalid URLs should include more edge cases to ensure robustness. Consider adding tests for: URLs with spaces (e.g., "example .com"), URLs with invalid characters, extremely long TLDs, domains starting with dots (e.g., "..example.com"), multiple consecutive dots (e.g., "example..com"), and malformed IPv6 addresses (e.g., "2001:db8:::1" with three colons).

Copilot uses AI. Check for mistakes.
}
}
63 changes: 34 additions & 29 deletions Plugins/Flow.Launcher.Plugin.Url/Main.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Windows.Controls;
using Flow.Launcher.Plugin.SharedCommands;
Expand All @@ -15,19 +16,28 @@ public class Main : IPlugin, IPluginI18n, ISettingProvider
// user:pass authentication
"(?:\\S+(?::\\S*)?@)?" +
"(?:" +
// IP address exclusion
// private & local networks
"(?!(?:10|127)(?:\\.\\d{1,3}){3})" +
"(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" +
"(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" +
// IP address dotted notation octets
// excludes loopback network 0.0.0.0
// excludes reserved space >= 224.0.0.0
// excludes network & broacast addresses
// (first & last IP address of each class)
"(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
"(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
"(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
// IPv6 address with optional brackets (brackets required if followed by port)
// IPv6 with brackets
"(?:\\[(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\]|" + // standard IPv6
"\\[(?:[0-9a-fA-F]{1,4}:){1,7}:\\]|" + // IPv6 with trailing ::
"\\[(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\\]|" + // IPv6 compressed
"\\[::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}\\]|" + // IPv6 with leading ::
"\\[(?:(?:[0-9a-fA-F]{1,4}:){1,6}|:):(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}\\]|" + // IPv6 with :: in the middle
"\\[::1\\])" + // IPv6 loopback
"|" +
Comment thread
VictoriousRaptor marked this conversation as resolved.
Outdated
// IPv6 without brackets (only when no port follows)
"(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|" + // standard IPv6
"(?:[0-9a-fA-F]{1,4}:){1,7}:|" + // IPv6 with trailing ::
"(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|" + // IPv6 compressed
"::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}|" + // IPv6 with leading ::
"(?:(?:[0-9a-fA-F]{1,4}:){1,6}|:):(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}|" + // IPv6 with :: in the middle
"::1)(?!:[0-9])" + // IPv6 loopback (not followed by port)
Comment thread
Jack251970 marked this conversation as resolved.
Outdated
"|" +
// IPv4 address - all valid addresses including private networks (excluding 0.0.0.0)
"(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|[1-9])\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d))" +
"|" +
// localhost
"localhost" +
"|" +
// host name
"(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)" +
Expand All @@ -37,20 +47,25 @@ public class Main : IPlugin, IPluginI18n, ISettingProvider
"(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))" +
")" +
// port number
"(?::\\d{2,5})?" +
"(?::\\d{1,5})?" +
Comment thread
Jack251970 marked this conversation as resolved.
Outdated
// resource path
"(?:/\\S*)?" +
"$";
private readonly Regex UrlRegex = new(UrlPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
internal static PluginInitContext Context { get; private set; }
internal static Settings Settings { get; private set; }

private static readonly string[] UrlSchemes = ["http://", "https://", "ftp://"];

public List<Result> Query(Query query)
{
var raw = query.Search;
if (IsURL(raw))
if (!IsURL(raw))
{
return
return [];
}
Comment thread
VictoriousRaptor marked this conversation as resolved.

return
[
new()
{
Expand All @@ -60,7 +75,8 @@ public List<Result> Query(Query query)
Score = 8,
Action = _ =>
{
if (!raw.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !raw.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
// not a recognized scheme, add preferred http scheme
if (!UrlSchemes.Any(scheme => raw.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)))
Comment thread
Jack251970 marked this conversation as resolved.
{
raw = GetHttpPreference() + "://" + raw;
Comment thread
VictoriousRaptor marked this conversation as resolved.
}
Comment on lines +44 to 48
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a bare IPv6 address is detected as valid (e.g., "2001:db8::1"), and a protocol needs to be added in the Action method (line 38), the code will create an invalid URL like "http://2001:db8::1". IPv6 addresses must be enclosed in brackets when used in URLs. The Action method should check if the input is a bare IPv6 address and wrap it in brackets before prepending the protocol, e.g., "http://[2001:db8::1]".

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VictoriousRaptor adding IPv6 support we will need to support this URL creation as well so this suggestion is needed to be addressed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VictoriousRaptor adding IPv6 support we will need to support this URL creation as well so this suggestion is needed to be addressed.

Good catch and will fix later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VictoriousRaptor adding IPv6 support we will need to support this URL creation as well so this suggestion is needed to be addressed.

@jjw24 Fixed and added this case to unit test. Please review.

Expand Down Expand Up @@ -92,9 +108,6 @@ public List<Result> Query(Query query)
}
}
];
}

return [];
}

private static string GetHttpPreference()
Expand All @@ -108,14 +121,6 @@ public bool IsURL(string raw)

if (UrlRegex.Match(raw).Value == raw) return true;

if (raw == "localhost" || raw.StartsWith("localhost:") ||
raw == "http://localhost" || raw.StartsWith("http://localhost:") ||
raw == "https://localhost" || raw.StartsWith("https://localhost:")
)
{
return true;
}

return false;
}

Expand Down
Loading