diff --git a/.github/workflows/ios-shared-web-tests.yml b/.github/workflows/ios-shared-web-tests.yml new file mode 100644 index 0000000000..8f3e068549 --- /dev/null +++ b/.github/workflows/ios-shared-web-tests.yml @@ -0,0 +1,74 @@ +name: iOS - PR Checks + +on: + push: + branches: [ main, "release/**", 'jkt/shared-web-tests' ] + pull_request: + workflow_call: + inputs: + branch: + description: "Branch name" + required: false + type: string + skip-release: + description: "Skip release build" + required: false + default: false + type: boolean + secrets: + APPLE_API_KEY_BASE64: + required: true + APPLE_API_KEY_ID: + required: true + APPLE_API_KEY_ISSUER: + required: true + ASANA_ACCESS_TOKEN: + required: true + MATCH_PASSWORD: + required: true + SSH_PRIVATE_KEY_FASTLANE_MATCH: + required: true + +jobs: + shared-web-tests: + name: Shared web tests + + runs-on: macos-15 + timeout-minutes: 20 + steps: + - name: Check out the code + if: github.event_name == 'pull_request' || github.event_name == 'push' + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_$(<.xcode-version).app/Contents/Developer + + - name: Checkout shared web tests + uses: actions/checkout@v4 + with: + submodules: recursive + repository: duckduckgo/shared-web-tests + path: shared-web-tests + ref: jkt/webdriver + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and Test + run: | + cd shared-web-tests + npm run build + sudo npm run install-hosts + + - name: Build iOS + run: | + source .maestro/common.sh + project_root=$(pwd) + build_app + + - name: Run tests + run: | + cd shared-web-tests + npm run test + + \ No newline at end of file diff --git a/.maestro/common.sh b/.maestro/common.sh index 7477b987cf..e9ceb57eb0 100644 --- a/.maestro/common.sh +++ b/.maestro/common.sh @@ -9,6 +9,15 @@ derived_data_path="$project_root"/DerivedData app_location="$derived_data_path/Build/Products/Debug-iphonesimulator/DuckDuckGo.app" device_uuid_path="$derived_data_path/device_uuid.txt" +# The simulator command requires the hyphens +target_device="iPhone-16" +target_os="iOS-18-2" + +# Convert the target_device and target_os to the format required by the -destination flag +destination_device="${target_device//-/ }" +destination_os_version="${target_os#iOS-}" +destination_os_version="${destination_os_version//-/.}" + echo echo "Configuration: " echo "project_root: $project_root" @@ -32,4 +41,24 @@ check_command() { fi } +build_app() { + if [ -d "$derived_data_path" ] && [ "$1" -eq "0" ]; then + echo "⚠️ Removing previously created $derived_data_path" + rm -rf $derived_data_path + else + echo "ℹ️ Not cleaning derived data at $derived_data_path" + fi + echo "⏲️ Building the app" + set -o pipefail && xcodebuild -project "$project_root"/DuckDuckGo-iOS.xcodeproj \ + -scheme "iOS Browser" \ + -destination "platform=iOS Simulator,name=$destination_device,OS=$destination_os_version" \ + -derivedDataPath "$derived_data_path" \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + ONLY_ACTIVE_ARCH=NO | tee xcodebuild.log + if [ $? -ne 0 ]; then + echo "‼️ Unable to build app into $derived_data_path" + exit 1 + fi +} diff --git a/.maestro/setup_ui_tests.sh b/.maestro/setup_ui_tests.sh index 3e46fb4910..5b2e387984 100755 --- a/.maestro/setup_ui_tests.sh +++ b/.maestro/setup_ui_tests.sh @@ -4,17 +4,6 @@ source $(dirname $0)/common.sh -## Constants - -# The simulator command requires the hyphens -target_device="iPhone-16" -target_os="iOS-18-2" - -# Convert the target_device and target_os to the format required by the -destination flag -destination_device="${target_device//-/ }" -destination_os_version="${target_os#iOS-}" -destination_os_version="${destination_os_version//-/.}" - ## Functions check_maestro() { @@ -36,7 +25,7 @@ check_maestro() { else echo "‼️ maestro not found install using the following commands:" echo - echo "curl -Ls \"https://get.maestro.mobile.dev\" | bash" + echo "export MAESTRO_VERSION=$known_version; curl -Ls "https://get.maestro.mobile.dev" | bash" echo "brew tap facebook/fb" echo "brew install facebook/fb/idb-companion" echo @@ -44,28 +33,6 @@ check_maestro() { fi } -build_app() { - if [ -d "$derived_data_path" ] && [ "$1" -eq "0" ]; then - echo "⚠️ Removing previously created $derived_data_path" - rm -rf $derived_data_path - else - echo "ℹ️ Not cleaning derived data at $derived_data_path" - fi - - echo "⏲️ Building the app" - set -o pipefail && xcodebuild -project "$project_root"/DuckDuckGo-iOS.xcodeproj \ - -scheme "iOS Browser" \ - -destination "platform=iOS Simulator,name=$destination_device,OS=$destination_os_version" \ - -derivedDataPath "$derived_data_path" \ - -skipPackagePluginValidation \ - -skipMacroValidation \ - ONLY_ACTIVE_ARCH=NO | tee xcodebuild.log - if [ $? -ne 0 ]; then - echo "‼️ Unable to build app into $derived_data_path" - exit 1 - fi -} - ## Main Script echo diff --git a/DuckDuckGo-iOS.xcodeproj/project.pbxproj b/DuckDuckGo-iOS.xcodeproj/project.pbxproj index aaf4709850..ed120eb935 100644 --- a/DuckDuckGo-iOS.xcodeproj/project.pbxproj +++ b/DuckDuckGo-iOS.xcodeproj/project.pbxproj @@ -445,7 +445,10 @@ 7B4F87EA2D0738F90010B18F /* SiriEducationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4F87E92D0738F40010B18F /* SiriEducationView.swift */; }; 7B4F87EC2D07396A0010B18F /* SiriEducation.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7B4F87EB2D07396A0010B18F /* SiriEducation.xcassets */; }; 7B4F87EE2D0739EB0010B18F /* SiriBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4F87ED2D0739E80010B18F /* SiriBubbleView.swift */; }; + 7B70CC3C2D15C0BC0096A1C6 /* AutomationServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B70CC3B2D15C0BC0096A1C6 /* AutomationServer.swift */; }; 7B8E0EC62CC81B4900B2B722 /* TipKitController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8E0EC52CC81B4800B2B722 /* TipKitController.swift */; }; + 7B9D532F2D5431E400D9E937 /* MainViewController+Automation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9D532E2D5431E400D9E937 /* MainViewController+Automation.swift */; }; + 7B9D53312D54321200D9E937 /* TabViewController+Automation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9D53302D54321200D9E937 /* TabViewController+Automation.swift */; }; 7BC571202BDBB877003B0CCE /* VPNActivationDateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */; }; 7BC571212BDBB977003B0CCE /* VPNActivationDateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */; }; 7BDBAD0E2CBFB3F1000379B7 /* VPN.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7BDBAD0D2CBFB3F1000379B7 /* VPN.xcassets */; }; @@ -1911,7 +1914,10 @@ 7B4F87E92D0738F40010B18F /* SiriEducationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiriEducationView.swift; sourceTree = ""; }; 7B4F87EB2D07396A0010B18F /* SiriEducation.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SiriEducation.xcassets; sourceTree = ""; }; 7B4F87ED2D0739E80010B18F /* SiriBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiriBubbleView.swift; sourceTree = ""; }; + 7B70CC3B2D15C0BC0096A1C6 /* AutomationServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationServer.swift; sourceTree = ""; }; 7B8E0EC52CC81B4800B2B722 /* TipKitController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipKitController.swift; sourceTree = ""; }; + 7B9D532E2D5431E400D9E937 /* MainViewController+Automation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainViewController+Automation.swift"; sourceTree = ""; }; + 7B9D53302D54321200D9E937 /* TabViewController+Automation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabViewController+Automation.swift"; sourceTree = ""; }; 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VPNActivationDateStore.swift; sourceTree = ""; }; 7BDBAD0D2CBFB3F1000379B7 /* VPN.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = VPN.xcassets; sourceTree = ""; }; 7BF78E012CA2CC3E0026A1FC /* TipKitAppEventHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipKitAppEventHandling.swift; sourceTree = ""; }; @@ -6685,6 +6691,7 @@ 984147C224F026A300362052 /* Tab.storyboard */, F1386BA31E6846C40062FC3C /* TabDelegate.swift */, F159BDA31F0BDB5A00B4A01D /* TabViewController.swift */, + 7B9D53302D54321200D9E937 /* TabViewController+Automation.swift */, 31D4CD4F2D4D3CD600BC27C6 /* Navigatable.swift */, CBC88EE42C8097B500F0F8C5 /* URLCredentialCreator.swift */, CB2A7EEE283D185100885F67 /* RulesCompilationMonitor.swift */, @@ -7066,6 +7073,7 @@ 85BA58541F34F49E00C6E8CA /* AppUserDefaults.swift */, 373608912ABB430D00629E7F /* FavoritesDisplayMode+UserDefaults.swift */, 850250B220D803F4002199C7 /* AtbAndVariantCleanup.swift */, + 7B70CC3B2D15C0BC0096A1C6 /* AutomationServer.swift */, 983EABB7236198F6003948D1 /* DatabaseMigration.swift */, 853C5F6021C277C7001F7A05 /* global.swift */, 85C8E61C2B0E47380029A6BD /* BookmarksDatabaseSetup.swift */, @@ -7088,6 +7096,7 @@ 8577A1C4255D2C0D00D43FCD /* HitTestingToolbar.swift */, 85DDE03F2AC6FF65006ABCA2 /* MainView.swift */, F17669D61E43401C003D3222 /* MainViewController.swift */, + 7B9D532E2D5431E400D9E937 /* MainViewController+Automation.swift */, 31D4CD4A2D4D1C2600BC27C6 /* ToolbarStateHandling.swift */, 566B736F2BECD46800FF1959 /* MainViewController+SyncAlerts.swift */, 981CA7E92617797500E119D5 /* MainViewController+AddFavoriteFlow.swift */, @@ -8273,6 +8282,7 @@ 851672D12BED1FC900592F24 /* AutocompleteView.swift in Sources */, 7B1681022D106CCC005EAE24 /* UserTextShared.swift in Sources */, 3161D13227AC161B00285CF6 /* DownloadMetadata.swift in Sources */, + 7B9D532F2D5431E400D9E937 /* MainViewController+Automation.swift in Sources */, D664C7C72B289AA200CBFA76 /* PurchaseInProgressView.swift in Sources */, 9F465E1E2D3F5C2E00490109 /* Logger+MaliciousSiteProtection.swift in Sources */, 7BFD5FD72C9DB9D7000FF959 /* VPNGeoswitchingTip.swift in Sources */, @@ -8391,6 +8401,7 @@ C12324C32C4697C900FBB26B /* AutofillBreakageReportTableViewCell.swift in Sources */, 8586A10D24CBA7070049720E /* FindInPageActivity.swift in Sources */, 9FB0271D2C293619009EA190 /* OnboardingIntroViewModel.swift in Sources */, + 7B70CC3C2D15C0BC0096A1C6 /* AutomationServer.swift in Sources */, 9FE08BD62C2A60CD001D5EBC /* MetricBuilder.swift in Sources */, 1E1626072968413B0004127F /* ViewExtension.swift in Sources */, 31A42566285A0A6300049386 /* FaviconViewModel.swift in Sources */, @@ -8617,6 +8628,7 @@ 1EDE39D22705D4A200C99C72 /* FileSizeDebugViewController.swift in Sources */, 4B412ACC2BBB3D0900A39F5E /* LazyView.swift in Sources */, 7B10FF252D11A56300F36BF2 /* ControlWidgetVPNIntents.swift in Sources */, + 7B9D53312D54321200D9E937 /* TabViewController+Automation.swift in Sources */, 85047C772A0D5D3D00D2FF3F /* SyncSettingsViewController+SyncDelegate.swift in Sources */, C12552972D0B06A100A0FDAA /* FreeTrialsFeatureFlagExperiment.swift in Sources */, 85DDE0402AC6FF65006ABCA2 /* MainView.swift in Sources */, diff --git a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift index 79f4ae7f04..e25bba162e 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift @@ -143,6 +143,7 @@ struct Launching: AppState { UserAgentConfiguration.configureUserBrowsingUserAgent() setupWindow() + startAutomationServerIfNeeded() } private func setupWindow() { @@ -179,7 +180,17 @@ struct Launching: AppState { maliciousSiteProtectionService: maliciousSiteProtectionService ) } - + + private func startAutomationServerIfNeeded() { + let launchOptionsHandler = LaunchOptionsHandler() + guard launchOptionsHandler.isUITesting && launchOptionsHandler.automationPort != nil else { + return + } + guard let rootViewController = window.rootViewController as? MainViewController else { + return + } + AutomationServer(main: rootViewController, port: launchOptionsHandler.automationPort) + } } extension Launching { diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift new file mode 100644 index 0000000000..ffbd7d380b --- /dev/null +++ b/DuckDuckGo/AutomationServer.swift @@ -0,0 +1,343 @@ +// +// AutomationServer.swift +// DuckDuckGo +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import Foundation +import Network + +extension Logger { + static var automationServer = { Logger(subsystem: Bundle.main.bundleIdentifier ?? "DuckDuckGo", category: "Automation Server") }() +} + + +final class AutomationServer { + let listener: NWListener + let main: MainViewController + + init(main: MainViewController, port: Int?) { + var port = port ?? 8786 + self.main = main + Logger.automationServer.info("Starting automation server on port \(port)") + do { + listener = try NWListener(using: .tcp, on: NWEndpoint.Port(integerLiteral: UInt16(port))) + } catch { + Logger.automationServer.error("Failed to start listener: \(error)") + fatalError("Failed to start automation listener: \(error)") + } + listener.newConnectionHandler = handleConnection + listener.start(queue: .main) + // Output server started + Logger.automationServer.info("Automation server started on port \(port)") + } + + @MainActor + func receive(from connection: NWConnection) { + connection.receive( + minimumIncompleteLength: 1, + maximumLength: connection.maximumDatagramSize + ) { content, _, isComplete, error in + switch connection.state { + case .ready: + break // Connection is valid, continue + case .cancelled, .failed: + print("Connection is no longer valid \(connection.state) \(String(describing: error)) \(String(describing: content)).") + return + default: + print("Connection is in state \(connection.state).") + return + } + Logger.automationServer.info("Received request! \(String(describing: content)) \(isComplete) \(String(describing: error))") + + if let error { + Logger.automationServer.error("Error: \(error)") + return + } + + if let content { + Logger.automationServer.info("Handling content") + Task { + await self.processContentWhenReady(connection: connection, content: content) + } + } + + if !isComplete { + Logger.automationServer.info("Handling not complete") + self.receive(from: connection) + } + } + } + + @MainActor + func processContentWhenReady(connection: NWConnection, content: Data) async { + // Check if loading + while self.main.currentTab?.isLoading ?? false { + print("Still loading, waiting...") + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + } + + // Proceed when loading is complete + Logger.automationServer.info("Handling content") + self.handleConnection(connection, content) + } + + func getQueryStringParameter(url: URLComponents, param: String) -> String? { + return url.queryItems?.first(where: { $0.name == param })?.value + } + + @MainActor + func handleConnection(_ connection: NWConnection, _ content: Data) { + Logger.automationServer.info("Handling request!") + let stringContent = String(decoding: content, as: UTF8.self) + // Log first line of string: + if let firstLine = stringContent.components(separatedBy: CharacterSet.newlines).first { + Logger.automationServer.info("First line: \(firstLine)") + } + + // Ensure support for regex + guard #available(iOS 16.0, *) else { + self.respondError(on: connection, error: "Unsupported iOS version") + return + } + + // Get url parameter from path + // GET / HTTP/1.1 + let path = /^(GET|POST) (\/[^ ]*) HTTP/ + guard let match = stringContent.firstMatch(of: path) else { + self.respondError(on: connection, error: "Unknown method") + return + } + Logger.automationServer.info("Path: \(match.2)") + // Convert the path into a URL object + guard let url = URLComponents(string: String(match.2)) else { + Logger.automationServer.error("Invalid URL: \(match.2)") + return // Or handle the error appropriately + } + switch url.path { + case "/navigate": + self.navigate(on: connection, url: url) + case "/execute": + self.execute(on: connection, url: url) + case "/getUrl": + let currentUrl = self.main.currentTab?.webView.url?.absoluteString + self.respond(on: connection, response: currentUrl ?? "") + case "/getWindowHandles": + self.getWindowHandles(on: connection, url: url) + case "/closeWindow": + self.closeWindow(on: connection, url: url) + case "/switchToWindow": + self.switchToWindow(on: connection, url: url) + case "/newWindow": + self.newWindow(on: connection, url: url) + case "/getWindowHandle": + self.getWindowHandle(on: connection, url: url) + default: + self.respondError(on: connection, error: "unknown") + } + } + + @MainActor + func navigate(on connection: NWConnection, url: URLComponents) { + let navigateUrlString = getQueryStringParameter(url: url, param: "url") ?? "" + let navigateUrl = URL(string: navigateUrlString)! + self.main.loadUrl(navigateUrl) + self.respond(on: connection, response: "done") + } + + @MainActor + func execute(on connection: NWConnection, url: URLComponents) { + let script = getQueryStringParameter(url: url, param: "script") ?? "" + var args: [String: String] = [:] + // json decode args if present + if let argsString = getQueryStringParameter(url: url, param: "args") { + guard let argsData = argsString.data(using: .utf8) else { + self.respondError(on: connection, error: "Unable to decode args") + return + } + do { + let jsonDecoder = JSONDecoder() + args = try jsonDecoder.decode([String: String].self, from: argsData) + } catch { + self.respondError(on: connection, error: error.localizedDescription) + return + } + } + Task { + await self.executeScript(script, args: args, on: connection) + } + } + + @MainActor + func getWindowHandle(on connection: NWConnection, url: URLComponents) { + let handle = self.main.currentTab + guard let handle else { + self.respondError(on: connection, error: "no window") + return + } + self.respond(on: connection, response: handle.tabModel.uid) + } + + @MainActor + func getWindowHandles(on connection: NWConnection, url: URLComponents) { + let handles = self.main.tabManager.model.tabs.map({ tab in + let tabView = self.main.tabManager.controller(for: tab)! + return tabView.tabModel.uid + }) + + if let jsonData = try? JSONEncoder().encode(handles), + let jsonString = String(data: jsonData, encoding: .utf8) { + self.respond(on: connection, response: jsonString) + } else { + // Handle JSON encoding failure + self.respondError(on: connection, error: "Failed to encode response") + } + } + + @MainActor + func closeWindow(on connection: NWConnection, url: URLComponents) { + self.main.closeTab(self.main.currentTab!.tabModel) + self.respond(on: connection, response: "{\"success\":true}") + } + + @MainActor + func switchToWindow(on connection: NWConnection, url: URLComponents) { + if let handleString = getQueryStringParameter(url: url, param: "handle") { + Logger.automationServer.info("Switch to window \(handleString)") + let tabToSelect: TabViewController? = nil + if let tabIndex = self.main.tabManager.model.tabs.firstIndex(where: { tab in + guard let tabView = self.main.tabManager.controller(for: tab) else { + return false + } + return tabView.tabModel.uid == handleString + }) { + Logger.automationServer.info("found tab \(tabIndex)") + self.main.tabManager.select(tabAt: tabIndex) + self.respond(on: connection, response: "{\"success\":true}") + } else { + self.respondError(on: connection, error: "Invalid window handle") + } + } else { + self.respondError(on: connection, error: "Invalid window handle") + } + } + + @MainActor + func newWindow(on connection: NWConnection, url: URLComponents) { + self.main.newTab() + let handle = self.main.tabManager.current(createIfNeeded: true) + guard let handle else { + self.respondError(on: connection, error: "no window") + return + } + // Response {handle: "", type: "tab"} + let response: [String: String] = ["handle": handle.tabModel.uid, "type": "tab"] + if let jsonData = try? JSONEncoder().encode(response), + let jsonString = String(data: jsonData, encoding: .utf8) { + self.respond(on: connection, response: jsonString) + } else { + self.respondError(on: connection, error: "Failed to encode response") + } + } + + func respondError(on connection: NWConnection, error: String) { + self.respond(on: connection, response: "{\"error\": \"\(error)\"}") + } + + func executeScript(_ script: String, args: [String: Any], on connection: NWConnection) async { + Logger.automationServer.info("Going to execute script: \(script)") + let result = await main.executeScript(script, args: args) + Logger.automationServer.info("Have result to execute script: \(String(describing: result))") + guard let result else { + return + } + do { + switch result { + case .failure(let error): + self.respond(on: connection, response: "{\"error\": \"\(error)\"}") + case .success(let value): + var jsonString: String = "" + + // Try to encode the value to JSON + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + Logger.automationServer.info("Have success value to execute script: \(String(describing: value))") + + // Serialize the value to JSON if possible + if value == nil { + jsonString = "{}" + } else if JSONSerialization.isValidJSONObject(value) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]) + jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" + } catch { + jsonString = "{\"error\": \"Failed to serialize value: \(error.localizedDescription)\"}" + } + } else { + Logger.automationServer.info("Have value that can't be encoded: \(String(describing: value))") + jsonString = "{\"error\": \"Value is not a valid JSON object\"}" + } + + // Send the response back with the JSON string + self.respond(on: connection, response: jsonString) + } + } catch { + self.respond(on: connection, response: "{\"error\": \"\(error)\"}") + } + } + + func respond(on connection: NWConnection, response: String? = nil) { + do { + if let response { + struct Response: Codable { + var message: String + } + let responseHeader = """ + HTTP/1.1 200 OK + Content-Type: application/json + Connection: close + + """ + var valueString = "" + if let stringValue = response as? String { + valueString = stringValue + } + let responseObject = Response(message: valueString) + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(responseObject) + let responseString = String(data: data, encoding: .utf8) ?? "" + let response = responseHeader + "\r\n" + responseString + connection.send( + content: response.data(using: .utf8), + completion: .contentProcessed({ error in + if let error = error { + Logger.automationServer.error("Error sending response: \(error)") + } + connection.cancel() + }) + ) + } + } catch { + Logger.automationServer.error("Got error encoding JSON: \(error)") + } + } + + @MainActor + func handleConnection(_ connection: NWConnection) { + connection.start(queue: .main) + self.receive(from: connection) + } +} diff --git a/DuckDuckGo/LaunchOptionsHandler.swift b/DuckDuckGo/LaunchOptionsHandler.swift index e62961e02d..901a58d6bc 100644 --- a/DuckDuckGo/LaunchOptionsHandler.swift +++ b/DuckDuckGo/LaunchOptionsHandler.swift @@ -23,8 +23,9 @@ public final class LaunchOptionsHandler { private static let isUITesting = "isUITesting" private static let isOnboardingcompleted = "isOnboardingCompleted" private static let appVariantName = "currentAppVariant" + private static let automationPort = "automationPort" - private let launchArguments: [String] + public let launchArguments: [String] private let userDefaults: UserDefaults public init(launchArguments: [String] = ProcessInfo.processInfo.arguments, userDefaults: UserDefaults = .app) { @@ -33,13 +34,17 @@ public final class LaunchOptionsHandler { } public var isUITesting: Bool { - launchArguments.contains(Self.isUITesting) + launchArguments.contains(Self.isUITesting) || userDefaults.bool(forKey: Self.isUITesting) } public var isOnboardingCompleted: Bool { userDefaults.string(forKey: Self.isOnboardingcompleted) == "true" } + public var automationPort: Int? { + userDefaults.integer(forKey: Self.automationPort) + } + public var appVariantName: String? { sanitisedEnvParameter(string: userDefaults.string(forKey: Self.appVariantName)) } diff --git a/DuckDuckGo/MainViewController+Automation.swift b/DuckDuckGo/MainViewController+Automation.swift new file mode 100644 index 0000000000..391cae8181 --- /dev/null +++ b/DuckDuckGo/MainViewController+Automation.swift @@ -0,0 +1,30 @@ +// +// MainViewController+Automation.swift +// DuckDuckGo +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +extension MainViewController { + + func executeScript(_ javaScriptString: String, + args: [String: Any] = [:]) async -> Result? { + var result = await currentTab?.executeScript(javaScriptString, args: args) + return result! // TODO fix ! + } + +} diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 8e43e0aab2..0eed22c813 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -508,8 +508,7 @@ class MainViewController: UIViewController { } func startOnboardingFlowIfNotSeenBefore() { - - guard ProcessInfo.processInfo.environment["ONBOARDING"] != "false" else { + guard !LaunchOptionsHandler().isOnboardingCompleted else { // explicitly skip onboarding, e.g. for integration tests return } @@ -1036,7 +1035,7 @@ class MainViewController: UIViewController { currentTab?.executeBookmarklet(url: url) } } - + private func loadBackForwardItem(_ item: WKBackForwardListItem) { prepareTabForRequest { currentTab?.load(backForwardListItem: item) diff --git a/DuckDuckGo/TabManager.swift b/DuckDuckGo/TabManager.swift index 573e2bff95..d381c05197 100644 --- a/DuckDuckGo/TabManager.swift +++ b/DuckDuckGo/TabManager.swift @@ -162,7 +162,7 @@ class TabManager { } } - private func controller(for tab: Tab) -> TabViewController? { + func controller(for tab: Tab) -> TabViewController? { return tabControllerCache.first { $0.tabModel === tab } } diff --git a/DuckDuckGo/TabViewController+Automation.swift b/DuckDuckGo/TabViewController+Automation.swift new file mode 100644 index 0000000000..1d6c361bbb --- /dev/null +++ b/DuckDuckGo/TabViewController+Automation.swift @@ -0,0 +1,38 @@ +// +// TabViewController+Automation.swift +// DuckDuckGo +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +extension TabViewController { + + @MainActor + public func executeScript(_ javaScriptString: String, + args: [String: Any] = [:]) async -> Result { + do { + var result = try await webView.callAsyncJavaScript( + javaScriptString, + arguments: args, + in: nil, + contentWorld: .page + ) ?? "" + return .success(result) + } catch { + return .failure(error) + } + } + +}