import ConjureKit import Foundation import Testing @Test func searchRanksExactPrefixWordPrefixInitialsSubstringAndFuzzyMatches() { let names = ["Logic", "Agc Utility", "Great Console", "Google Chrome", "Tool GC Helper", "GCompiler", "GC", "Unrelated"] let applications = names.map(application) #expect(ApplicationSearch.results(for: "gc", in: applications).map(\.name) == [ "GC", "GCompiler", "Tool GC Helper", "Google Chrome", "Great Console", "Agc Utility", "Logic", ]) #expect(ApplicationSearch.results(for: "zq", in: applications).isEmpty) } @Test func wordPrefixesIncludePartialPhrasesWithoutPromotingInteriorSubstrings() { let applications = ["ADisk Utility", "Open Disk Utility", "Disk Utility"].map(application) #expect(ApplicationSearch.results(for: "disk u", in: applications).map(\.name) == [ "Disk Utility", "Open Disk Utility", "ADisk Utility", ]) } @Test func wordStartsHandlePunctuationDigitsAndAccentFolding() { let cases = [ ("ss", "System_Settings", "Mississippi"), ("2m", "Audio 2 MIDI", "A2M Tools"), (" \n ÉN \t ", "Éditeur Numérique", "Aen Utility"), ("sys", "★ System Settings", "Asystem"), ] for (query, name, interiorMatch) in cases { let applications = [interiorMatch, name].map(application) #expect(ApplicationSearch.results(for: query, in: applications).map(\.name) == [ name, interiorMatch, ]) } } @Test func wordInitialsMatchInOrderAndCanSkipWords() { let applications = ["Vocal", "Avoid Chords", "Voice Control", "Visual Studio Code"].map(application) #expect(ApplicationSearch.results(for: "vc", in: applications).map(\.name) == [ "Visual Studio Code", "Voice Control", "Vocal", "Avoid Chords", ]) #expect(ApplicationSearch.results(for: "vsc", in: applications).map(\.name) == [ "Visual Studio Code", ]) #expect(ApplicationSearch.results(for: "cv", in: applications).isEmpty) } @Test func searchFoldsAccentsAndTrimsWhitespace() { let applications = ["Decafé", "Caféteria", "Café", "Camera"].map(application) #expect(ApplicationSearch.results(for: " \n CAFÉ \t ", in: applications).map(\.name) == [ "Café", "Caféteria", "Decafé", ]) } @Test func fuzzyMatchesPreferCompactSpansThenEarlierStartsAndAlphabeticalTies() { let applications = ["AxxxxB", "xAyB", "AzB", "AyB", "B before A"].map(application) #expect(ApplicationSearch.results(for: "ab", in: applications).map(\.name) == [ "AyB", "AzB", "xAyB", "AxxxxB", ]) } @Test func emptySearchSortsAlphabeticallyAndBreaksDuplicateNameTiesByURL() { let first = Application(url: URL(fileURLWithPath: "/First/Tools.app"), name: "Tools") let second = Application(url: URL(fileURLWithPath: "/Second/Tools.app"), name: "Tools") let applications = [second, first, application("Alpha")] for query in ["", " \n\t "] { #expect(ApplicationSearch.results(for: query, in: applications).map(\.url) == [ applications[2].url, first.url, second.url, ]) } #expect(ApplicationSearch.results(for: "tools", in: applications).map(\.url) == [first.url, second.url]) } @Test func discoveryFindsNestedAppsButNotHiddenInvalidOrBundledHelpers() throws { let root = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: root) } let nested = root.appendingPathComponent("Utilities", isDirectory: true) let alpha = try appBundle(in: nested, filename: "Alpha", name: "Alpha") let parent = try appBundle(in: root, filename: "Parent", name: "Parent") _ = try appBundle(in: parent.appendingPathComponent("Contents/Helpers"), filename: "Helper", name: "Helper") _ = try appBundle(in: root, filename: ".Hidden", name: "Hidden") _ = try appBundle(in: root, filename: "NoExecutable", name: "NoExecutable", executable: false) _ = try appBundle(in: root, filename: "WrongType", name: "WrongType", packageType: "BNDL") let fallback = try appBundle(in: root, filename: "Fallback", name: nil) let display = try appBundle(in: root, filename: "Filename", name: "Internal", displayName: "Unlocalized") let localization = display.appendingPathComponent("Contents/Resources/en.lproj", isDirectory: true) try FileManager.default.createDirectory(at: localization, withIntermediateDirectories: true) let localizedInfo = try PropertyListSerialization.data( fromPropertyList: ["CFBundleDisplayName": "Display"], format: .xml, options: 0 ) try localizedInfo.write(to: localization.appendingPathComponent("InfoPlist.strings")) let invalid = root.appendingPathComponent("Invalid.app", isDirectory: true) _ = try appBundle(in: invalid, filename: "OtherHelper", name: "OtherHelper") let applications = ApplicationCatalog.discover(in: [root, root.appendingPathComponent("Missing")]) #expect(applications.map(\.name) == ["Alpha", "Display", "Fallback", "Parent"]) #expect(applications.map(\.url) == [alpha, display, fallback, parent].map { $0.resolvingSymlinksInPath() }) } @Test func discoveryFindsSearchableXPCAppsButNotTheirHelpersOrInvalidBundles() throws { let root = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: root) } let passwords = try appBundle(in: root, filename: "Passwords", name: "Passwords", packageType: "XPC!") _ = try appBundle(in: passwords.appendingPathComponent("Contents/Helpers"), filename: "Helper", name: "Helper", packageType: "XPC!") _ = try appBundle(in: root, filename: "NoExecutable", name: "NoExecutable", executable: false, packageType: "XPC!") let applications = ApplicationCatalog.discover(in: [root]) #expect(applications.map(\.url) == [passwords.resolvingSymlinksInPath()]) #expect(ApplicationSearch.results(for: "passwords", in: applications).map(\.name) == ["Passwords"]) } @Test func discoveryCanonicalizesAliasesAndAvoidsDirectorySymlinkCycles() throws { let root = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: root) } let nested = root.appendingPathComponent("Nested", isDirectory: true) let app = try appBundle(in: nested, filename: "Editor", name: "Editor") let alias = root.appendingPathComponent("Alias.app") try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: app) try FileManager.default.createSymbolicLink(at: nested.appendingPathComponent("Loop"), withDestinationURL: root) let linkedDirectory = root.appendingPathComponent("LinkedDirectory") try FileManager.default.createSymbolicLink(at: linkedDirectory, withDestinationURL: nested) let applications = ApplicationCatalog.discover(in: [root, linkedDirectory, root]) #expect(applications.count == 1) #expect(applications.first?.id == app.resolvingSymlinksInPath()) #expect(Application(url: alias, name: "Alias").id == applications.first?.id) } private func application(_ name: String) -> Application { Application(url: URL(fileURLWithPath: "/Applications/\(name).app"), name: name) } private func temporaryDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } @discardableResult private func appBundle( in directory: URL, filename: String, name: String?, displayName: String? = nil, executable: Bool = true, packageType: String = "APPL" ) throws -> URL { let url = directory.appendingPathComponent("\(filename).app", isDirectory: true) let contents = url.appendingPathComponent("Contents", isDirectory: true) let macOS = contents.appendingPathComponent("MacOS", isDirectory: true) try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true) var info: [String: Any] = [ "CFBundlePackageType": packageType, "CFBundleExecutable": "Launcher", "CFBundleDevelopmentRegion": "en", "CFBundleIdentifier": "test.conjure.\(UUID().uuidString)", ] info["CFBundleName"] = name info["CFBundleDisplayName"] = displayName let data = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) try data.write(to: contents.appendingPathComponent("Info.plist")) if executable { let binary = macOS.appendingPathComponent("Launcher") try Data("#!/bin/sh\nexit 0\n".utf8).write(to: binary) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) } return url }