Rules-based browser launcher for TUI + GNOME. switchyard.aly.codes
tui gome bowser go
0

Configure Feed

Select the types of activity you want to include in your feed.

add more tests

Aly Raffauf (Jan 16, 2026, 4:42 PM EST) fd49e7fd a2dbddd5

+567
+567
src/config_test.go
··· 758 758 }) 759 759 } 760 760 } 761 + 762 + // TestExtractDomain_EdgeCases tests URL domain extraction with edge cases 763 + func TestExtractDomain_EdgeCases(t *testing.T) { 764 + tests := []struct { 765 + name string 766 + url string 767 + expected string 768 + }{ 769 + { 770 + name: "URL with authentication credentials strips at colon", 771 + url: "https://user:password@example.com/path", 772 + expected: "user", // Note: extractDomain strips at colon (port removal logic) 773 + }, 774 + { 775 + name: "URL with username only", 776 + url: "https://user@example.com/path", 777 + expected: "user@example.com", 778 + }, 779 + { 780 + name: "internationalized domain (IDN) ASCII form", 781 + url: "https://xn--n3h.com/path", 782 + expected: "xn--n3h.com", 783 + }, 784 + { 785 + name: "internationalized domain Unicode", 786 + url: "https://münchen.example/path", 787 + expected: "münchen.example", 788 + }, 789 + { 790 + name: "very long subdomain", 791 + url: "https://this.is.a.very.long.subdomain.chain.example.com/path", 792 + expected: "this.is.a.very.long.subdomain.chain.example.com", 793 + }, 794 + { 795 + name: "IP address v4", 796 + url: "https://192.168.1.1/path", 797 + expected: "192.168.1.1", 798 + }, 799 + { 800 + name: "IP address v4 with port", 801 + url: "https://192.168.1.1:8080/path", 802 + expected: "192.168.1.1", 803 + }, 804 + { 805 + name: "localhost", 806 + url: "http://localhost/path", 807 + expected: "localhost", 808 + }, 809 + { 810 + name: "localhost with port", 811 + url: "http://localhost:3000/path", 812 + expected: "localhost", 813 + }, 814 + { 815 + name: "single word TLD", 816 + url: "https://localhost", 817 + expected: "localhost", 818 + }, 819 + { 820 + name: "double slash in path doesn't affect domain", 821 + url: "https://example.com//double//slashes", 822 + expected: "example.com", 823 + }, 824 + { 825 + name: "ftp protocol", 826 + url: "ftp://files.example.com/file.zip", 827 + expected: "files.example.com", 828 + }, 829 + { 830 + name: "custom protocol", 831 + url: "myapp://example.com/action", 832 + expected: "example.com", 833 + }, 834 + { 835 + name: "mailto protocol extracts scheme name", 836 + url: "mailto:user@example.com", 837 + expected: "mailto", // Note: mailto: has no //, so extractDomain returns text before colon 838 + }, 839 + { 840 + name: "URL with empty path", 841 + url: "https://example.com/", 842 + expected: "example.com", 843 + }, 844 + { 845 + name: "URL with only question mark", 846 + url: "https://example.com?", 847 + expected: "example.com?", 848 + }, 849 + } 850 + 851 + for _, tt := range tests { 852 + t.Run(tt.name, func(t *testing.T) { 853 + result := extractDomain(tt.url) 854 + if result != tt.expected { 855 + t.Errorf("extractDomain(%q) = %q, want %q", tt.url, result, tt.expected) 856 + } 857 + }) 858 + } 859 + } 860 + 861 + // TestMatchesPattern_EdgeCases tests pattern matching with edge case URLs 862 + func TestMatchesPattern_EdgeCases(t *testing.T) { 863 + tests := []struct { 864 + name string 865 + url string 866 + pattern string 867 + patternType string 868 + expected bool 869 + }{ 870 + // Long URL tests 871 + { 872 + name: "very long URL with keyword match", 873 + url: "https://example.com/" + string(make([]byte, 1000)) + "keyword" + string(make([]byte, 1000)), 874 + pattern: "keyword", 875 + patternType: "keyword", 876 + expected: true, 877 + }, 878 + { 879 + name: "URL with many query parameters", 880 + url: "https://example.com/path?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10", 881 + pattern: "example.com", 882 + patternType: "domain", 883 + expected: true, 884 + }, 885 + // Special character tests 886 + { 887 + name: "URL with encoded spaces", 888 + url: "https://example.com/path%20with%20spaces", 889 + pattern: "%20", 890 + patternType: "keyword", 891 + expected: true, 892 + }, 893 + { 894 + name: "URL with plus signs", 895 + url: "https://search.example.com/q=hello+world", 896 + pattern: "hello+world", 897 + patternType: "keyword", 898 + expected: true, 899 + }, 900 + { 901 + name: "URL with hash fragment", 902 + url: "https://example.com/page#section-id", 903 + pattern: "section-id", 904 + patternType: "keyword", 905 + expected: true, 906 + }, 907 + { 908 + name: "URL with unicode characters", 909 + url: "https://example.com/日本語", 910 + pattern: "日本語", 911 + patternType: "keyword", 912 + expected: true, 913 + }, 914 + // Data URI tests 915 + { 916 + name: "data URI matches domain 'data' since it extracts scheme", 917 + url: "data:text/html,<h1>Hello</h1>", 918 + pattern: "data", 919 + patternType: "domain", 920 + expected: true, // extractDomain returns "data" for data: URIs 921 + }, 922 + { 923 + name: "data URI matches keyword", 924 + url: "data:text/html,<h1>Hello</h1>", 925 + pattern: "text/html", 926 + patternType: "keyword", 927 + expected: true, 928 + }, 929 + // JavaScript URI tests 930 + { 931 + name: "javascript URI keyword match", 932 + url: "javascript:alert('test')", 933 + pattern: "javascript", 934 + patternType: "keyword", 935 + expected: true, 936 + }, 937 + // Blob URI tests 938 + { 939 + name: "blob URI keyword match", 940 + url: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000", 941 + pattern: "blob:", 942 + patternType: "keyword", 943 + expected: true, 944 + }, 945 + // Regex edge cases 946 + { 947 + name: "regex with special characters in URL", 948 + url: "https://example.com/path?key=value&other=123", 949 + pattern: `key=value.*other=\d+`, 950 + patternType: "regex", 951 + expected: true, 952 + }, 953 + { 954 + name: "regex matching entire URL", 955 + url: "https://subdomain.example.com/path", 956 + pattern: `^https://[a-z]+\.example\.com/.*$`, 957 + patternType: "regex", 958 + expected: true, 959 + }, 960 + // Glob edge cases 961 + { 962 + name: "glob with multiple wildcards", 963 + url: "https://api.v2.example.com/endpoint", 964 + pattern: "*.*.example.com", 965 + patternType: "glob", 966 + expected: true, 967 + }, 968 + { 969 + name: "glob matching only TLD", 970 + url: "https://anything.io/path", 971 + pattern: "*.io", 972 + patternType: "glob", 973 + expected: true, 974 + }, 975 + // Empty and whitespace tests 976 + { 977 + name: "URL with only whitespace in query", 978 + url: "https://example.com/search?q= ", 979 + pattern: " ", 980 + patternType: "keyword", 981 + expected: true, 982 + }, 983 + // Case sensitivity verification 984 + { 985 + name: "domain match is case insensitive", 986 + url: "https://EXAMPLE.COM/path", 987 + pattern: "example.com", 988 + patternType: "domain", 989 + expected: true, 990 + }, 991 + { 992 + name: "keyword match is case insensitive", 993 + url: "https://example.com/PATH", 994 + pattern: "path", 995 + patternType: "keyword", 996 + expected: true, 997 + }, 998 + } 999 + 1000 + for _, tt := range tests { 1001 + t.Run(tt.name, func(t *testing.T) { 1002 + result := matchesPattern(tt.url, tt.pattern, tt.patternType) 1003 + if result != tt.expected { 1004 + t.Errorf("matchesPattern(%q, %q, %q) = %v, want %v", 1005 + tt.url, tt.pattern, tt.patternType, result, tt.expected) 1006 + } 1007 + }) 1008 + } 1009 + } 1010 + 1011 + // TestConfigMatchRule_RuleOrdering tests that rules are matched in order (first match wins) 1012 + func TestConfigMatchRule_RuleOrdering(t *testing.T) { 1013 + tests := []struct { 1014 + name string 1015 + config Config 1016 + url string 1017 + wantBrowserID string 1018 + wantMatched bool 1019 + }{ 1020 + { 1021 + name: "first matching rule wins", 1022 + config: Config{ 1023 + Rules: []Rule{ 1024 + { 1025 + Name: "First Rule", 1026 + Browser: "first-browser.desktop", 1027 + Logic: "all", 1028 + Conditions: []Condition{ 1029 + {Type: "domain", Pattern: "example.com"}, 1030 + }, 1031 + }, 1032 + { 1033 + Name: "Second Rule", 1034 + Browser: "second-browser.desktop", 1035 + Logic: "all", 1036 + Conditions: []Condition{ 1037 + {Type: "domain", Pattern: "example.com"}, 1038 + }, 1039 + }, 1040 + }, 1041 + }, 1042 + url: "https://example.com/path", 1043 + wantBrowserID: "first-browser.desktop", 1044 + wantMatched: true, 1045 + }, 1046 + { 1047 + name: "more specific rule first", 1048 + config: Config{ 1049 + Rules: []Rule{ 1050 + { 1051 + Name: "Specific Rule", 1052 + Browser: "specific-browser.desktop", 1053 + Logic: "all", 1054 + Conditions: []Condition{ 1055 + {Type: "domain", Pattern: "docs.example.com"}, 1056 + }, 1057 + }, 1058 + { 1059 + Name: "General Rule", 1060 + Browser: "general-browser.desktop", 1061 + Logic: "all", 1062 + Conditions: []Condition{ 1063 + {Type: "glob", Pattern: "*.example.com"}, 1064 + }, 1065 + }, 1066 + }, 1067 + }, 1068 + url: "https://docs.example.com/guide", 1069 + wantBrowserID: "specific-browser.desktop", 1070 + wantMatched: true, 1071 + }, 1072 + { 1073 + name: "general rule matches when specific doesn't", 1074 + config: Config{ 1075 + Rules: []Rule{ 1076 + { 1077 + Name: "Specific Rule", 1078 + Browser: "specific-browser.desktop", 1079 + Logic: "all", 1080 + Conditions: []Condition{ 1081 + {Type: "domain", Pattern: "docs.example.com"}, 1082 + }, 1083 + }, 1084 + { 1085 + Name: "General Rule", 1086 + Browser: "general-browser.desktop", 1087 + Logic: "all", 1088 + Conditions: []Condition{ 1089 + {Type: "glob", Pattern: "*.example.com"}, 1090 + }, 1091 + }, 1092 + }, 1093 + }, 1094 + url: "https://api.example.com/endpoint", 1095 + wantBrowserID: "general-browser.desktop", 1096 + wantMatched: true, 1097 + }, 1098 + { 1099 + name: "non-matching rules are skipped", 1100 + config: Config{ 1101 + Rules: []Rule{ 1102 + { 1103 + Name: "Non-matching Rule", 1104 + Browser: "wrong-browser.desktop", 1105 + Logic: "all", 1106 + Conditions: []Condition{ 1107 + {Type: "domain", Pattern: "other.com"}, 1108 + }, 1109 + }, 1110 + { 1111 + Name: "Matching Rule", 1112 + Browser: "correct-browser.desktop", 1113 + Logic: "all", 1114 + Conditions: []Condition{ 1115 + {Type: "domain", Pattern: "example.com"}, 1116 + }, 1117 + }, 1118 + }, 1119 + }, 1120 + url: "https://example.com/path", 1121 + wantBrowserID: "correct-browser.desktop", 1122 + wantMatched: true, 1123 + }, 1124 + { 1125 + name: "empty rules list", 1126 + config: Config{ 1127 + Rules: []Rule{}, 1128 + }, 1129 + url: "https://example.com/path", 1130 + wantBrowserID: "", 1131 + wantMatched: false, 1132 + }, 1133 + { 1134 + name: "all rules fail to match", 1135 + config: Config{ 1136 + Rules: []Rule{ 1137 + { 1138 + Name: "Rule 1", 1139 + Browser: "browser1.desktop", 1140 + Logic: "all", 1141 + Conditions: []Condition{ 1142 + {Type: "domain", Pattern: "other1.com"}, 1143 + }, 1144 + }, 1145 + { 1146 + Name: "Rule 2", 1147 + Browser: "browser2.desktop", 1148 + Logic: "all", 1149 + Conditions: []Condition{ 1150 + {Type: "domain", Pattern: "other2.com"}, 1151 + }, 1152 + }, 1153 + }, 1154 + }, 1155 + url: "https://example.com/path", 1156 + wantBrowserID: "", 1157 + wantMatched: false, 1158 + }, 1159 + { 1160 + name: "rule with empty conditions is skipped", 1161 + config: Config{ 1162 + Rules: []Rule{ 1163 + { 1164 + Name: "Empty Rule", 1165 + Browser: "empty-browser.desktop", 1166 + Logic: "all", 1167 + Conditions: []Condition{}, 1168 + }, 1169 + { 1170 + Name: "Valid Rule", 1171 + Browser: "valid-browser.desktop", 1172 + Logic: "all", 1173 + Conditions: []Condition{ 1174 + {Type: "domain", Pattern: "example.com"}, 1175 + }, 1176 + }, 1177 + }, 1178 + }, 1179 + url: "https://example.com/path", 1180 + wantBrowserID: "valid-browser.desktop", 1181 + wantMatched: true, 1182 + }, 1183 + { 1184 + name: "AND rule fails partial match, next rule matches", 1185 + config: Config{ 1186 + Rules: []Rule{ 1187 + { 1188 + Name: "AND Rule", 1189 + Browser: "and-browser.desktop", 1190 + Logic: "all", 1191 + Conditions: []Condition{ 1192 + {Type: "domain", Pattern: "example.com"}, 1193 + {Type: "keyword", Pattern: "admin"}, // won't match 1194 + }, 1195 + }, 1196 + { 1197 + Name: "Fallback Rule", 1198 + Browser: "fallback-browser.desktop", 1199 + Logic: "all", 1200 + Conditions: []Condition{ 1201 + {Type: "domain", Pattern: "example.com"}, 1202 + }, 1203 + }, 1204 + }, 1205 + }, 1206 + url: "https://example.com/user", 1207 + wantBrowserID: "fallback-browser.desktop", 1208 + wantMatched: true, 1209 + }, 1210 + } 1211 + 1212 + for _, tt := range tests { 1213 + t.Run(tt.name, func(t *testing.T) { 1214 + browserID, _, matched := tt.config.matchRule(tt.url) 1215 + if browserID != tt.wantBrowserID { 1216 + t.Errorf("Config.matchRule(%q) browserID = %q, want %q", 1217 + tt.url, browserID, tt.wantBrowserID) 1218 + } 1219 + if matched != tt.wantMatched { 1220 + t.Errorf("Config.matchRule(%q) matched = %v, want %v", 1221 + tt.url, matched, tt.wantMatched) 1222 + } 1223 + }) 1224 + } 1225 + } 1226 + 1227 + // TestSanitizeURL_EdgeCases tests URL sanitization with edge cases 1228 + func TestSanitizeURL_EdgeCases(t *testing.T) { 1229 + tests := []struct { 1230 + name string 1231 + url string 1232 + expected string 1233 + }{ 1234 + // Note: sanitizeURL checks for "://" to identify schemes. 1235 + // URIs with single colon (data:, mailto:, tel:, javascript:) get https:// prepended. 1236 + // This is acceptable for a browser URL router since these aren't routable web URLs. 1237 + { 1238 + name: "data URI gets https prefix (no :// in original)", 1239 + url: "data:text/html,<h1>Test</h1>", 1240 + expected: "https://data:text/html,<h1>Test</h1>", 1241 + }, 1242 + { 1243 + name: "javascript URI gets https prefix (no :// in original)", 1244 + url: "javascript:void(0)", 1245 + expected: "https://javascript:void(0)", 1246 + }, 1247 + { 1248 + name: "blob URI is preserved (has ://)", 1249 + url: "blob:https://example.com/guid", 1250 + expected: "blob:https://example.com/guid", 1251 + }, 1252 + { 1253 + name: "mailto URI gets https prefix (no :// in original)", 1254 + url: "mailto:user@example.com", 1255 + expected: "https://mailto:user@example.com", 1256 + }, 1257 + { 1258 + name: "tel URI gets https prefix (no :// in original)", 1259 + url: "tel:+1234567890", 1260 + expected: "https://tel:+1234567890", 1261 + }, 1262 + { 1263 + name: "URL with leading whitespace", 1264 + url: " https://example.com", 1265 + expected: "https://example.com", 1266 + }, 1267 + { 1268 + name: "URL with trailing whitespace", 1269 + url: "https://example.com ", 1270 + expected: "https://example.com", 1271 + }, 1272 + { 1273 + name: "URL with both leading and trailing whitespace", 1274 + url: " https://example.com ", 1275 + expected: "https://example.com", 1276 + }, 1277 + { 1278 + name: "bare domain gets https prefix", 1279 + url: "example.com", 1280 + expected: "https://example.com", 1281 + }, 1282 + { 1283 + name: "bare domain with path gets https prefix", 1284 + url: "example.com/path/to/page", 1285 + expected: "https://example.com/path/to/page", 1286 + }, 1287 + { 1288 + name: "relative path is rejected", 1289 + url: "./relative/path", 1290 + expected: "", 1291 + }, 1292 + { 1293 + name: "absolute path is rejected", 1294 + url: "/absolute/path", 1295 + expected: "", 1296 + }, 1297 + { 1298 + name: "empty string", 1299 + url: "", 1300 + expected: "", 1301 + }, 1302 + { 1303 + name: "only whitespace", 1304 + url: " ", 1305 + expected: "", 1306 + }, 1307 + { 1308 + name: "ftp URL is preserved", 1309 + url: "ftp://files.example.com/file.zip", 1310 + expected: "ftp://files.example.com/file.zip", 1311 + }, 1312 + { 1313 + name: "custom app scheme is preserved", 1314 + url: "myapp://action/param", 1315 + expected: "myapp://action/param", 1316 + }, 1317 + } 1318 + 1319 + for _, tt := range tests { 1320 + t.Run(tt.name, func(t *testing.T) { 1321 + result := sanitizeURL(tt.url) 1322 + if result != tt.expected { 1323 + t.Errorf("sanitizeURL(%q) = %q, want %q", tt.url, result, tt.expected) 1324 + } 1325 + }) 1326 + } 1327 + }