From 97c7254eaee4e54d393ea95a72c96ff95d6bfe27 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Fri, 20 Dec 2024 15:27:09 +0000 Subject: [PATCH 01/23] Big ol backup --- .../browser_features/shared_web_tests.yaml | 31 ++++ .maestro/run_shared_web_tests.sh | 21 +++ .maestro/setup_ui_tests.sh | 10 +- .maestro/shared/create_bookmarklette.yaml | 21 +++ .maestro/shared/get-url.yaml | 11 ++ .maestro/shared/set-url.yaml | 13 ++ DuckDuckGo.xcodeproj/project.pbxproj | 4 + DuckDuckGo/AppDelegate.swift | 2 + DuckDuckGo/AutomationServer.swift | 171 ++++++++++++++++++ DuckDuckGo/MainViewController.swift | 19 ++ DuckDuckGo/TabViewController.swift | 16 ++ 11 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 .maestro/browser_features/shared_web_tests.yaml create mode 100644 .maestro/run_shared_web_tests.sh create mode 100644 .maestro/shared/create_bookmarklette.yaml create mode 100644 .maestro/shared/get-url.yaml create mode 100644 .maestro/shared/set-url.yaml create mode 100644 DuckDuckGo/AutomationServer.swift diff --git a/.maestro/browser_features/shared_web_tests.yaml b/.maestro/browser_features/shared_web_tests.yaml new file mode 100644 index 0000000000..2f15ca6ca4 --- /dev/null +++ b/.maestro/browser_features/shared_web_tests.yaml @@ -0,0 +1,31 @@ +# tabs.yaml +appId: com.duckduckgo.mobile.ios +tags: + - release + +--- + +# Set up +- runFlow: + file: ../shared/setup.yaml + +# Load Site +- assertVisible: + id: "searchEntry" +- tapOn: + id: "searchEntry" +- inputText: "https://web-platform.test:9000/tools/runner/index.html" +- pressKey: Enter + +- tapOn: "Start" +- assertVisible: "Download JSON results" +- tapOn: "Download JSON results" +- tapOn: "Save To Downloads" + +- assertVisible: "Passed" +# Do something else to validate we downloaded +#- assertVisible: +# childOf: +# id: "output" +# text: "1" + diff --git a/.maestro/run_shared_web_tests.sh b/.maestro/run_shared_web_tests.sh new file mode 100644 index 0000000000..3565266141 --- /dev/null +++ b/.maestro/run_shared_web_tests.sh @@ -0,0 +1,21 @@ +pwd=$(pwd) +source "$pwd/.maestro/run_ui_tests.sh" .maestro/browser_features/shared_web_tests.yaml +app_dir=$(xcrun simctl get_app_container booted $app_bundle data) + +echo "ℹ️ Running shared web tests" + +echo "Ensure runner results were collected" +runner_results_path="$app_dir/Documents/Downloads/runner-results.json" +if [ ! -f "$runner_results_path" ]; then + echo "‼️ No runner results found at $runner_results_path" + exit 1 +fi +echo "✅ Found runner results at $runner_results_path" +tail -n 1 "$runner_results_path" + +echo "ℹ️ Parsing runner results" +runner_results=$(cat "$runner_results_path") +if [ -z "$runner_results" ]; then + echo "‼️ No runner results found" + exit 1 +fi diff --git a/.maestro/setup_ui_tests.sh b/.maestro/setup_ui_tests.sh index d47d106b1a..e820ba32e6 100755 --- a/.maestro/setup_ui_tests.sh +++ b/.maestro/setup_ui_tests.sh @@ -31,7 +31,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 @@ -79,6 +79,8 @@ while [[ "$#" -gt 0 ]]; do case $1 in --skip-build) skip_build=1 ;; + --only-build) + only_build=1 ;; --rebuild) rebuild=1 ;; *) @@ -92,6 +94,11 @@ else build_app $rebuild fi +if [ -n "$only_build" ]; then + echo "ℹ️ Only building the app. Exiting." + exit 0 +fi + echo "ℹ️ Closing all simulators" killall Simulator @@ -107,6 +114,7 @@ fi echo "📱 Using simulator $device_uuid" xcrun simctl boot $device_uuid +xcrun simctl keychain booted add-root-cert ../shared-web-tests/web-platform-tests/tools/certs/cacert.pem if [ $? -ne 0 ]; then echo "‼️ Unable to boot simulator" exit 1 diff --git a/.maestro/shared/create_bookmarklette.yaml b/.maestro/shared/create_bookmarklette.yaml new file mode 100644 index 0000000000..9801c2fccf --- /dev/null +++ b/.maestro/shared/create_bookmarklette.yaml @@ -0,0 +1,21 @@ +appId: com.duckduckgo.mobile.ios +--- + +- tapOn: + id: searchEntry +- inputText: localhost +- pressKey: Enter +- tapOn: Browsing Menu +- tapOn: Add Bookmark +- tapOn: Edit +- tapOn: + id: title +- inputText: ${TITLE} +- tapOn: + id: url +- inputText: ${URL} +- tapOn: Save +- tapOn: Done +- tapOn: Browsing Menu +- tapOn: Bookmarks +- tapOn: ${TITLE} \ No newline at end of file diff --git a/.maestro/shared/get-url.yaml b/.maestro/shared/get-url.yaml new file mode 100644 index 0000000000..4abc761a6e --- /dev/null +++ b/.maestro/shared/get-url.yaml @@ -0,0 +1,11 @@ +# onboarding.yaml + +appId: com.duckduckgo.mobile.ios +--- + +- copyTextFrom: + id: "searchEntry" +- assertVisible: + text: ${maestro.copiedText} +- evalScript: ${console.log('URL text:' + JSON.stringify(maestro.copiedText))} +- evalScript: ${throw new Error('URL text:' + JSON.stringify(maestro.copiedText))} \ No newline at end of file diff --git a/.maestro/shared/set-url.yaml b/.maestro/shared/set-url.yaml new file mode 100644 index 0000000000..ece3fff375 --- /dev/null +++ b/.maestro/shared/set-url.yaml @@ -0,0 +1,13 @@ +# onboarding.yaml + +appId: com.duckduckgo.mobile.ios +--- + +- tapOn: + id: "searchEntry" +#- longPressOn: +# id: "searchEntry" +#- tapOn: 'Select All' +- eraseText: 1000 +- inputText: ${URL} +- pressKey: Enter \ No newline at end of file diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 654bc03135..a2496d048a 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -369,6 +369,7 @@ 7B1604E82CB685B400A44EC6 /* Logger+TipKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */; }; 7B1604EC2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */; }; 7B1604EE2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604ED2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift */; }; + 7B70CC3C2D15C0BC0096A1C6 /* AutomationServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B70CC3B2D15C0BC0096A1C6 /* AutomationServer.swift */; }; 7B8E0EC62CC81B4900B2B722 /* TipKitController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8E0EC52CC81B4800B2B722 /* TipKitController.swift */; }; 7BC571202BDBB877003B0CCE /* VPNActivationDateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */; }; 7BC571212BDBB977003B0CCE /* VPNActivationDateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5711F2BDBB877003B0CCE /* VPNActivationDateStore.swift */; }; @@ -1681,6 +1682,7 @@ 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+TipKit.swift"; sourceTree = ""; }; 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TipKitController+ConvenienceInitializers.swift"; sourceTree = ""; }; 7B1604ED2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipKitDebugOptionsUIActionHandling.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 = ""; }; 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 = ""; }; @@ -6280,6 +6282,7 @@ 85BA58541F34F49E00C6E8CA /* AppUserDefaults.swift */, 373608912ABB430D00629E7F /* FavoritesDisplayMode+UserDefaults.swift */, 850250B220D803F4002199C7 /* AtbAndVariantCleanup.swift */, + 7B70CC3B2D15C0BC0096A1C6 /* AutomationServer.swift */, 983EABB7236198F6003948D1 /* DatabaseMigration.swift */, 853C5F6021C277C7001F7A05 /* global.swift */, 85C8E61C2B0E47380029A6BD /* BookmarksDatabaseSetup.swift */, @@ -7552,6 +7555,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 */, diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 8020b7ad4b..170c2440f0 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -360,6 +360,8 @@ import os.log subscriptionCookieManager: subscriptionCookieManager, textZoomCoordinator: makeTextZoomCoordinator()) + AutomationServer(main: main) + main.loadViewIfNeeded() syncErrorHandler.alertPresenter = main diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift new file mode 100644 index 0000000000..dbe43d038d --- /dev/null +++ b/DuckDuckGo/AutomationServer.swift @@ -0,0 +1,171 @@ +// +// AppDelegate.swift +// DuckDuckGo +// +// Copyright © 2024 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 + +class AutomationServer { + let listener: NWListener + let main: MainViewController + + init(main: MainViewController) { + self.main = main + listener = try! NWListener(using: .tcp, on: 8786) + listener.newConnectionHandler = handleConnection + // listener.start(queue: .global()) + listener.start(queue: .main) + } + + func receive(from connection: NWConnection) { + connection.receive( + minimumIncompleteLength: 1, + maximumLength: connection.maximumDatagramSize + ) { content, _, isComplete, error in + + // if isLoading delay + if (self.main.currentTab?.isLoading ?? false) { + // wait for 1 second + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.receive(from: connection) + } + } + + func getQueryStringParameter(url: String, param: String) -> String? { + guard let url = URLComponents(string: url) else { return nil } + return url.queryItems?.first(where: { $0.name == param })?.value + } + + if let error { + print("Error: \(error)") + } else if let content { + print("Received request!") + let stringContent = String(decoding: content, as: UTF8.self) + print(String(data: content, encoding: .utf8)!) + // Get url parameter from path + // GET / HTTP/1.1 + if #available(iOS 16.0, *) { + let path = /^(GET|POST) (\/[^ ]*) HTTP/ + if let match = stringContent.firstMatch(of: path) { + print("Path: \(match.2)") + // Convert the path into a URL object + let url = URL(string: String(match.2))! + if url.path == "/navigate" { + let navigateUrlString = getQueryStringParameter(url: String(match.2), param: "url") ?? "" + let navigateUrl = URL(string: navigateUrlString)! + self.main.navigateTo(url: navigateUrl) + self.respond(on: connection, response: "done") + } else if url.path == "/execute" { + let script = getQueryStringParameter(url: String(match.2), param: "script") ?? "" + var args: [String: String] = [:] + // json decode args + if let argsString = getQueryStringParameter(url: String(match.2), param: "args") { + if let argsData = argsString.data(using: .utf8) { + do { + let jsonDecoder = JSONDecoder() + args = try jsonDecoder.decode([String: String].self, from: argsData) + } catch { + self.respond(on: connection, response: "{\"error\": \"\(error.localizedDescription)\", \"args\": \"\(argsString)\"}") + } + } else { + self.respond(on: connection, response: "{\"error\": \"Unable to decode args\"}") + } + } + self.executeScript(script, args: args, on: connection) + } else if url.path == "/getUrl" { + self.respond(on: connection, response: self.main.currentUrl() ?? "") + } else { + self.respond(on: connection, response: "unknown") + } + } else { + self.respond(on: connection, response: "unknown method") + } + } else { + self.respond(on: connection, response: "unhandled") + } + } + + if !isComplete { + self.receive(from: connection) + } + } + } + + func executeScript(_ script: String, args: [String: Any], on connection: NWConnection) { + main.executeScript(script, args: args) { result in + do { + switch result { + case .failure(let error): + self.respond(on: connection, response: "{\"error\": \"\(error)\"}"); + return + case .success(let value): + var valueString: String = "" + if let stringValue = value as? String { + valueString = stringValue + } + self.respond(on: connection, response: valueString) + break + } + } 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 { + print("Error sending response: \(error)") + } + connection.cancel() + }) + ) + } + } catch { + Swift.print("Got error encoding JSON: \(error)") + } + } + + func handleConnection(_ connection: NWConnection) { + //connection.start(queue: .global()) + connection.start(queue: .main) + self.receive(from: connection) + } +} diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index c6b5f59729..9839549d7f 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -952,6 +952,15 @@ class MainViewController: UIViewController { } fileprivate func loadQuery(_ query: String) { + // Convert query to URL: + if let url = URL(string: query) { + if url.isBookmarklet() { + executeBookmarklet(url) + refreshOmniBar() + // self.currentTab.url + return + } + } guard let url = URL.makeSearchURL(query: query, queryContext: currentTab?.url) else { Logger.general.error("Couldn‘t form URL for query “\(query, privacy: .public)” with context “\(self.currentTab?.url?.absoluteString ?? "", privacy: .public)”") return @@ -980,6 +989,16 @@ class MainViewController: UIViewController { } } + func executeScript(_ javaScriptString: String, + args: [String: Any] = [:], + completionHandler: @escaping (Result) -> Void) { + currentTab?.executeScript(javaScriptString, args: args, completionHandler: completionHandler) + } + + func currentUrl() -> String? { + return currentTab?.getUrl() + } + private func loadBackForwardItem(_ item: WKBackForwardListItem) { prepareTabForRequest { currentTab?.load(backForwardListItem: item) diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index a5bbd41846..9a7957d9ad 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -676,6 +676,22 @@ class TabViewController: UIViewController { webView.evaluateJavaScript(js) } } + + public func executeScript(_ javaScriptString: String, + args: [String: Any] = [:], + completionHandler: @escaping (Result) -> Void) { + webView.callAsyncJavaScript( + javaScriptString, + arguments: args, + in: nil, + in: .defaultClient, + completionHandler: completionHandler + ) + } + + public func getUrl() -> String? { + return webView.url?.absoluteString + } public func load(url: URL) { wasLoadingStoppedExternally = false From 8b76ca3a47239f8a6feb731162bf0e87cc715d80 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Fri, 24 Jan 2025 10:14:20 +0000 Subject: [PATCH 02/23] Backup of existing work --- .maestro/setup_ui_tests.sh | 1 + .maestro/shared/setup.yaml | 1 + DuckDuckGo/AppDelegate.swift | 11 +- DuckDuckGo/AutomationServer.swift | 301 +++++++++++++++++++------- DuckDuckGo/LaunchOptionsHandler.swift | 5 + DuckDuckGo/MainViewController.swift | 17 +- DuckDuckGo/TabManager.swift | 2 +- DuckDuckGo/TabViewController.swift | 24 +- 8 files changed, 263 insertions(+), 99 deletions(-) diff --git a/.maestro/setup_ui_tests.sh b/.maestro/setup_ui_tests.sh index e820ba32e6..1142936b4a 100755 --- a/.maestro/setup_ui_tests.sh +++ b/.maestro/setup_ui_tests.sh @@ -115,6 +115,7 @@ echo "📱 Using simulator $device_uuid" xcrun simctl boot $device_uuid xcrun simctl keychain booted add-root-cert ../shared-web-tests/web-platform-tests/tools/certs/cacert.pem +xcrun simctl keychain booted add-root-cert ../shared-web-tests/web-platform-tests/build/tools/certs/cacert.pem if [ $? -ne 0 ]; then echo "‼️ Unable to boot simulator" exit 1 diff --git a/.maestro/shared/setup.yaml b/.maestro/shared/setup.yaml index e74e4d3d49..1c61627b9c 100644 --- a/.maestro/shared/setup.yaml +++ b/.maestro/shared/setup.yaml @@ -15,6 +15,7 @@ appId: com.duckduckgo.mobile.ios isUITesting: true # Renaming `isUITesting` requires to update LaunchOptionsHandler `isUITesting` key isOnboardingCompleted: ${ONBOARDING_COMPLETED} # Renaming `isOnboardingCompleted` requires to update LaunchOptionsHandler `isOnboardingCompleted` key currentAppVariant: ${APP_VARIANT} # Renaming `currentAppVariant` requires to update LaunchOptionsHandler `currentAppVariant` key + automationPort: ${AUTOMATION_PORT} # Get past onboarding screens - runFlow: diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 170c2440f0..ff8c5cfe10 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -360,7 +360,12 @@ import os.log subscriptionCookieManager: subscriptionCookieManager, textZoomCoordinator: makeTextZoomCoordinator()) - AutomationServer(main: main) +//#if DEBUG + print("Automation port: \(launchOptionsHandler.automationPort ?? 0)") + if launchOptionsHandler.isUITesting { + AutomationServer(main: main, port: launchOptionsHandler.automationPort) + } +//#endif main.loadViewIfNeeded() syncErrorHandler.alertPresenter = main @@ -849,6 +854,8 @@ import os.log let historyMessageManager = HistoryMessageManager() AtbAndVariantCleanup.cleanup() + daxDialogs.dismiss() + /* variantManager.assignVariantIfNeeded { _ in // MARK: perform first time launch logic here // If it's running UI Tests check if the onboarding should be in a completed state. @@ -863,7 +870,7 @@ import os.log // Setup storage for marketplace postback marketplaceAdPostbackManager.updateReturningUserValue() - } + }*/ } private func initialiseBackgroundFetch(_ application: UIApplication) { diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift index dbe43d038d..aa410fc9b4 100644 --- a/DuckDuckGo/AutomationServer.swift +++ b/DuckDuckGo/AutomationServer.swift @@ -19,110 +19,264 @@ import Foundation import Network +/* +// WebDriver BiDi automation server +class AutomationServerBidi { + let listener: NWListener + let main: MainViewController + + init(main: MainViewController, port: Int?) { + self.main = main + listener = try! NWListener(using: .tcp, on: NWEndpoint.Port(integerLiteral: UInt16(port ?? 8786))) + listener.newConnectionHandler = handleConnection + // listener.start(queue: .global()) + listener.start(queue: .main) + } +} +*/ + + +struct Log: TextOutputStream { + + func write(_ string: String) { + let fm = FileManager.default + let log = fm.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("log.txt") + if let handle = try? FileHandle(forWritingTo: log) { + handle.seekToEndOfFile() + handle.write(string.data(using: .utf8)!) + handle.closeFile() + } else { + try? string.data(using: .utf8)?.write(to: log) + } + } +} + class AutomationServer { let listener: NWListener let main: MainViewController + var logger: Log + + init(main: MainViewController, port: Int?) { + self.logger = Log() - init(main: MainViewController) { self.main = main - listener = try! NWListener(using: .tcp, on: 8786) + listener = try! NWListener(using: .tcp, on: NWEndpoint.Port(integerLiteral: UInt16(port ?? 8786))) listener.newConnectionHandler = handleConnection // listener.start(queue: .global()) listener.start(queue: .main) } + @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) \(error) \(content).", to: &self.logger) + return + default: + print("Connection is in state \(connection.state).", to: &self.logger) + return + } + print("Received request! \(content) \(isComplete) \(error)", to: &self.logger) + + if let error { + print("Error: \(error)", to: &self.logger) + return + } - // if isLoading delay - if (self.main.currentTab?.isLoading ?? false) { - // wait for 1 second - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.receive(from: connection) + if let content { + print("Handling content", to: &self.logger) + Task { + await self.processContentWhenReady(connection: connection, content: content) } } - func getQueryStringParameter(url: String, param: String) -> String? { - guard let url = URLComponents(string: url) else { return nil } - return url.queryItems?.first(where: { $0.name == param })?.value + if !isComplete { + print("Handling not complete", to: &self.logger) + self.receive(from: connection) } - - if let error { - print("Error: \(error)") - } else if let content { - print("Received request!") - let stringContent = String(decoding: content, as: UTF8.self) - print(String(data: content, encoding: .utf8)!) - // Get url parameter from path - // GET / HTTP/1.1 - if #available(iOS 16.0, *) { - let path = /^(GET|POST) (\/[^ ]*) HTTP/ - if let match = stringContent.firstMatch(of: path) { - print("Path: \(match.2)") - // Convert the path into a URL object - let url = URL(string: String(match.2))! - if url.path == "/navigate" { - let navigateUrlString = getQueryStringParameter(url: String(match.2), param: "url") ?? "" - let navigateUrl = URL(string: navigateUrlString)! - self.main.navigateTo(url: navigateUrl) - self.respond(on: connection, response: "done") - } else if url.path == "/execute" { - let script = getQueryStringParameter(url: String(match.2), param: "script") ?? "" - var args: [String: String] = [:] - // json decode args - if let argsString = getQueryStringParameter(url: String(match.2), param: "args") { - if let argsData = argsString.data(using: .utf8) { - do { - let jsonDecoder = JSONDecoder() - args = try jsonDecoder.decode([String: String].self, from: argsData) - } catch { - self.respond(on: connection, response: "{\"error\": \"\(error.localizedDescription)\", \"args\": \"\(argsString)\"}") - } - } else { - self.respond(on: connection, response: "{\"error\": \"Unable to decode args\"}") - } + } + } + + @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 + print("Handling content", to: &self.logger) + self.handleConnection(connection, content) + } + + @MainActor + func handleConnection(_ connection: NWConnection, _ content: Data) { + print("Handling request!", to: &self.logger) + let stringContent = String(decoding: content, as: UTF8.self) + // Log first line of string: + if let firstLine = stringContent.components(separatedBy: CharacterSet.newlines).first { + print(firstLine, to: &self.logger) + } + + func getQueryStringParameter(url: String, param: String) -> String? { + guard let url = URLComponents(string: url) else { return nil } + return url.queryItems?.first(where: { $0.name == param })?.value + } + // Get url parameter from path + // GET / HTTP/1.1 + if #available(iOS 16.0, *) { + let path = /^(GET|POST) (\/[^ ]*) HTTP/ + if let match = stringContent.firstMatch(of: path) { + print("Path: \(match.2)", to: &logger) + // Convert the path into a URL object + guard let url = URL(string: String(match.2)) else { + print("Invalid URL: \(match.2)") + return // Or handle the error appropriately + } + if url.path == "/navigate" { + let navigateUrlString = getQueryStringParameter(url: String(match.2), param: "url") ?? "" + let navigateUrl = URL(string: navigateUrlString)! + self.main.navigateTo(url: navigateUrl) + self.respond(on: connection, response: "done") + } else if url.path == "/execute" { + let script = getQueryStringParameter(url: String(match.2), param: "script") ?? "" + var args: [String: String] = [:] + print("Script: \(script)", to: &self.logger) + // json decode args + if let argsString = getQueryStringParameter(url: String(match.2), param: "args") { + if let argsData = argsString.data(using: .utf8) { + do { + let jsonDecoder = JSONDecoder() + args = try jsonDecoder.decode([String: String].self, from: argsData) + } catch { + self.respond(on: connection, response: "{\"error\": \"\(error.localizedDescription)\", \"args\": \"\(argsString)\"}") + } + } else { + self.respond(on: connection, response: "{\"error\": \"Unable to decode args\"}") + } + } + Task { + await self.executeScript(script, args: args, on: connection) + } + } else if url.path == "/getUrl" { + self.respond(on: connection, response: self.main.currentUrl() ?? "") + } else if url.path == "/getWindowHandles" { + // TODO get all tabs + let handle = self.main.tabManager.current(createIfNeeded: true) + guard let handle else { + self.respond(on: connection, response: "no window") + return + } + + let handles = self.main.tabManager.model.tabs.map({ tab in + let tabView = self.main.tabManager.controller(for: tab)! + return String(UInt(bitPattern: ObjectIdentifier(tabView))) + }) + + 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.respond(on: connection, response: "{\"error\":\"Failed to encode response\"}") + } + } else if url.path == "/closeWindow" { + self.main.closeTab(self.main.currentTab!.tabModel) + self.respond(on: connection, response: "{\"success\":true}") + } else if url.path == "/switchToWindow" { + if let handleString = getQueryStringParameter(url: String(match.2), param: "handle") { + print("Switch to window \(handleString)", to: &logger) + 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 } - self.executeScript(script, args: args, on: connection) - } else if url.path == "/getUrl" { - self.respond(on: connection, response: self.main.currentUrl() ?? "") + return String(UInt(bitPattern: ObjectIdentifier(tabView))) == handleString + }) { + print("found tab \(tabIndex)", to: &logger) + self.main.tabManager.select(tabAt: tabIndex) + self.respond(on: connection, response: "{\"success\":true}") } else { - self.respond(on: connection, response: "unknown") + self.respond(on: connection, response: "{\"error\":\"Invalid window handle\"}") } } else { - self.respond(on: connection, response: "unknown method") + self.respond(on: connection, response: "{\"error\":\"Invalid window handle\"}") + } + } else if url.path == "/newWindow" { + self.main.newTab() + let handle = self.main.tabManager.current(createIfNeeded: true) + guard let handle else { + self.respond(on: connection, response: "no window") + return + } + // Response {handle: "", type: "tab"} + let response: [String: String] = ["handle": String(UInt(bitPattern: ObjectIdentifier(handle))), "type": "tab"] + if let jsonData = try? JSONEncoder().encode(response), + let jsonString = String(data: jsonData, encoding: .utf8) { + self.respond(on: connection, response: jsonString) + } else { + self.respond(on: connection, response: "{\"error\":\"Failed to encode response\"}") + } + } else if url.path == "/getWindowHandle" { + let handle = self.main.currentTab + guard let handle else { + self.respond(on: connection, response: "no window") + return } + self.respond(on: connection, response: String(UInt(bitPattern: ObjectIdentifier(handle)))) } else { - self.respond(on: connection, response: "unhandled") + self.respond(on: connection, response: "unknown") } + } else { + self.respond(on: connection, response: "unknown method") } - - if !isComplete { - self.receive(from: connection) - } + } else { + self.respond(on: connection, response: "unhandled") } } - func executeScript(_ script: String, args: [String: Any], on connection: NWConnection) { - main.executeScript(script, args: args) { result in - do { - switch result { - case .failure(let error): - self.respond(on: connection, response: "{\"error\": \"\(error)\"}"); - return - case .success(let value): - var valueString: String = "" - if let stringValue = value as? String { - valueString = stringValue + func executeScript(_ script: String, args: [String: Any], on connection: NWConnection) async { + print("Going to execute script: \(script)", to: &self.logger) + var result = await main.executeScript(script, args: args) + print("Have result to execute script: \(result)", to: &self.logger) + guard var 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 + + // Serialize the value to JSON if possible + 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)\"}" } - self.respond(on: connection, response: valueString) - break + } else { + jsonString = "{\"error\": \"Value is not a valid JSON object\"}" } - } catch { - self.respond(on: connection, response: "{\"error\": \"\(error)\"}") + + // Send the response back with the JSON string + self.respond(on: connection, response: jsonString) } + } catch { + self.respond(on: connection, response: "{\"error\": \"\(error)\"}") } } @@ -152,19 +306,20 @@ class AutomationServer { content: response.data(using: .utf8), completion: .contentProcessed({ error in if let error = error { - print("Error sending response: \(error)") + print("Error sending response: \(error)", to: &self.logger) } connection.cancel() }) ) } } catch { - Swift.print("Got error encoding JSON: \(error)") + print("Got error encoding JSON: \(error)", to: &logger) } } + @MainActor func handleConnection(_ connection: NWConnection) { - //connection.start(queue: .global()) + // connection.start(queue: .global()) connection.start(queue: .main) self.receive(from: connection) } diff --git a/DuckDuckGo/LaunchOptionsHandler.swift b/DuckDuckGo/LaunchOptionsHandler.swift index e62961e02d..246de4dc13 100644 --- a/DuckDuckGo/LaunchOptionsHandler.swift +++ b/DuckDuckGo/LaunchOptionsHandler.swift @@ -23,6 +23,7 @@ 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] private let userDefaults: UserDefaults @@ -40,6 +41,10 @@ public final class LaunchOptionsHandler { 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.swift b/DuckDuckGo/MainViewController.swift index 9839549d7f..12bd9d062c 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -952,15 +952,6 @@ class MainViewController: UIViewController { } fileprivate func loadQuery(_ query: String) { - // Convert query to URL: - if let url = URL(string: query) { - if url.isBookmarklet() { - executeBookmarklet(url) - refreshOmniBar() - // self.currentTab.url - return - } - } guard let url = URL.makeSearchURL(query: query, queryContext: currentTab?.url) else { Logger.general.error("Couldn‘t form URL for query “\(query, privacy: .public)” with context “\(self.currentTab?.url?.absoluteString ?? "", privacy: .public)”") return @@ -990,15 +981,15 @@ class MainViewController: UIViewController { } func executeScript(_ javaScriptString: String, - args: [String: Any] = [:], - completionHandler: @escaping (Result) -> Void) { - currentTab?.executeScript(javaScriptString, args: args, completionHandler: completionHandler) + args: [String: Any] = [:]) async -> Result? { + var result = await currentTab?.executeScript(javaScriptString, args: args) + return result! // TODO fix ! } func currentUrl() -> String? { return currentTab?.getUrl() } - + private func loadBackForwardItem(_ item: WKBackForwardListItem) { prepareTabForRequest { currentTab?.load(backForwardListItem: item) diff --git a/DuckDuckGo/TabManager.swift b/DuckDuckGo/TabManager.swift index 2690c7b518..a666afed3d 100644 --- a/DuckDuckGo/TabManager.swift +++ b/DuckDuckGo/TabManager.swift @@ -132,7 +132,7 @@ class TabManager { } } - private func controller(for tab: Tab) -> TabViewController? { + public func controller(for tab: Tab) -> TabViewController? { return tabControllerCache.first { $0.tabModel === tab } } diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 9a7957d9ad..22ea12753c 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -676,17 +676,21 @@ class TabViewController: UIViewController { webView.evaluateJavaScript(js) } } - + + @MainActor public func executeScript(_ javaScriptString: String, - args: [String: Any] = [:], - completionHandler: @escaping (Result) -> Void) { - webView.callAsyncJavaScript( - javaScriptString, - arguments: args, - in: nil, - in: .defaultClient, - completionHandler: completionHandler - ) + args: [String: Any] = [:]) async -> Result { + do { + var result = try await webView.callAsyncJavaScript( + javaScriptString, + arguments: args, + in: nil, + contentWorld: .page // .defaultClient, + ) + return .success(result) + } catch { + return .failure(error) + } } public func getUrl() -> String? { From 78032318107969076a77e7ddc91ff399324542c7 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 4 Feb 2025 11:00:44 +0000 Subject: [PATCH 03/23] Use launch args and logging --- DuckDuckGo/AppDelegate.swift | 7 -- .../AppLifecycle/AppStates/Launching.swift | 11 +++ DuckDuckGo/AutomationServer.swift | 72 +++++++++---------- DuckDuckGo/LaunchOptionsHandler.swift | 4 +- DuckDuckGo/MainViewController.swift | 3 +- 5 files changed, 46 insertions(+), 51 deletions(-) diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index b7faa107c2..22f501ff0a 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -35,13 +35,6 @@ import Core /// See: Launching.swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { -//#if DEBUG - print("Automation port: \(launchOptionsHandler.automationPort ?? 0)") - if launchOptionsHandler.isUITesting { - AutomationServer(main: main, port: launchOptionsHandler.automationPort) - } -//#endif - let isTesting: Bool = ProcessInfo().arguments.contains("testing") appStateMachine.handle(.didFinishLaunching(application, isTesting: isTesting)) return true diff --git a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift index 5c6fa26108..b837513b37 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift @@ -35,6 +35,7 @@ import Combine import PixelKit import PixelExperimentKit + /// Represents the transient state where the app is being prepared for user interaction after being launched by the system. /// - Usage: /// - This state is typically associated with the `application(_:didFinishLaunchingWithOptions:)` method. @@ -524,6 +525,7 @@ struct Launching: AppState { } tipKitAppEventsHandler.appDidFinishLaunching() + startAutomationServer() } private var appDependencies: AppDependencies { @@ -634,6 +636,15 @@ struct Launching: AppState { dataStoreIDManager: dataStoreIDManager) } + private func startAutomationServer() { +//#if DEBUG + let launchOptionsHandler = LaunchOptionsHandler() + if launchOptionsHandler.isUITesting && launchOptionsHandler.automationPort != nil { + AutomationServer(main: mainViewController!, port: launchOptionsHandler.automationPort) + } +//#endif + } + } extension Launching { diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift index aa410fc9b4..94e26630a2 100644 --- a/DuckDuckGo/AutomationServer.swift +++ b/DuckDuckGo/AutomationServer.swift @@ -19,28 +19,15 @@ import Foundation import Network -/* -// WebDriver BiDi automation server -class AutomationServerBidi { - let listener: NWListener - let main: MainViewController - - init(main: MainViewController, port: Int?) { - self.main = main - listener = try! NWListener(using: .tcp, on: NWEndpoint.Port(integerLiteral: UInt16(port ?? 8786))) - listener.newConnectionHandler = handleConnection - // listener.start(queue: .global()) - listener.start(queue: .main) - } +extension Logger { + static var automationServer = { Logger(subsystem: Bundle.main.bundleIdentifier ?? "DuckDuckGo", category: "Automation Server") }() } -*/ - struct Log: TextOutputStream { func write(_ string: String) { let fm = FileManager.default - let log = fm.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("log.txt") + let log = fm.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("log-automation.txt") if let handle = try? FileHandle(forWritingTo: log) { handle.seekToEndOfFile() handle.write(string.data(using: .utf8)!) @@ -54,16 +41,18 @@ struct Log: TextOutputStream { class AutomationServer { let listener: NWListener let main: MainViewController - var logger: Log init(main: MainViewController, port: Int?) { - self.logger = Log() - + var port = port ?? 8786 self.main = main - listener = try! NWListener(using: .tcp, on: NWEndpoint.Port(integerLiteral: UInt16(port ?? 8786))) + var log = Log() + print("Starting automation server on port \(port)", to: &log) + print("Bundle: \(Bundle.main.bundleIdentifier)", to: &log) + listener = try! NWListener(using: .tcp, on: NWEndpoint.Port(integerLiteral: UInt16(port))) listener.newConnectionHandler = handleConnection - // listener.start(queue: .global()) listener.start(queue: .main) + // Output server started + Logger.automationServer.info("Automation server started on port \(port)") } @MainActor @@ -76,28 +65,28 @@ class AutomationServer { case .ready: break // Connection is valid, continue case .cancelled, .failed: - print("Connection is no longer valid \(connection.state) \(error) \(content).", to: &self.logger) + print("Connection is no longer valid \(connection.state) \(String(describing: error)) \(String(describing: content)).") return default: - print("Connection is in state \(connection.state).", to: &self.logger) + print("Connection is in state \(connection.state).") return } - print("Received request! \(content) \(isComplete) \(error)", to: &self.logger) + Logger.automationServer.info("Received request! \(String(describing: content)) \(isComplete) \(String(describing: error))") if let error { - print("Error: \(error)", to: &self.logger) + Logger.automationServer.error("Error: \(error)") return } if let content { - print("Handling content", to: &self.logger) + Logger.automationServer.info("Handling content") Task { await self.processContentWhenReady(connection: connection, content: content) } } if !isComplete { - print("Handling not complete", to: &self.logger) + Logger.automationServer.info("Handling not complete") self.receive(from: connection) } } @@ -112,17 +101,17 @@ class AutomationServer { } // Proceed when loading is complete - print("Handling content", to: &self.logger) + Logger.automationServer.info("Handling content") self.handleConnection(connection, content) } @MainActor func handleConnection(_ connection: NWConnection, _ content: Data) { - print("Handling request!", to: &self.logger) + 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 { - print(firstLine, to: &self.logger) + Logger.automationServer.info("First line: \(firstLine)") } func getQueryStringParameter(url: String, param: String) -> String? { @@ -134,7 +123,7 @@ class AutomationServer { if #available(iOS 16.0, *) { let path = /^(GET|POST) (\/[^ ]*) HTTP/ if let match = stringContent.firstMatch(of: path) { - print("Path: \(match.2)", to: &logger) + Logger.automationServer.info("Path: \(match.2)") // Convert the path into a URL object guard let url = URL(string: String(match.2)) else { print("Invalid URL: \(match.2)") @@ -143,12 +132,11 @@ class AutomationServer { if url.path == "/navigate" { let navigateUrlString = getQueryStringParameter(url: String(match.2), param: "url") ?? "" let navigateUrl = URL(string: navigateUrlString)! - self.main.navigateTo(url: navigateUrl) + self.main.loadUrl(navigateUrl) self.respond(on: connection, response: "done") } else if url.path == "/execute" { let script = getQueryStringParameter(url: String(match.2), param: "script") ?? "" var args: [String: String] = [:] - print("Script: \(script)", to: &self.logger) // json decode args if let argsString = getQueryStringParameter(url: String(match.2), param: "args") { if let argsData = argsString.data(using: .utf8) { @@ -192,7 +180,7 @@ class AutomationServer { self.respond(on: connection, response: "{\"success\":true}") } else if url.path == "/switchToWindow" { if let handleString = getQueryStringParameter(url: String(match.2), param: "handle") { - print("Switch to window \(handleString)", to: &logger) + 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 { @@ -200,7 +188,7 @@ class AutomationServer { } return String(UInt(bitPattern: ObjectIdentifier(tabView))) == handleString }) { - print("found tab \(tabIndex)", to: &logger) + Logger.automationServer.info("found tab \(tabIndex)") self.main.tabManager.select(tabAt: tabIndex) self.respond(on: connection, response: "{\"success\":true}") } else { @@ -243,9 +231,9 @@ class AutomationServer { } func executeScript(_ script: String, args: [String: Any], on connection: NWConnection) async { - print("Going to execute script: \(script)", to: &self.logger) + Logger.automationServer.info("Going to execute script: \(script)") var result = await main.executeScript(script, args: args) - print("Have result to execute script: \(result)", to: &self.logger) + Logger.automationServer.info("Have result to execute script: \(String(describing: result))") guard var result else { return } @@ -259,9 +247,12 @@ class AutomationServer { // 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 JSONSerialization.isValidJSONObject(value) { + 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) ?? "{}" @@ -269,6 +260,7 @@ class AutomationServer { 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\"}" } @@ -306,14 +298,14 @@ class AutomationServer { content: response.data(using: .utf8), completion: .contentProcessed({ error in if let error = error { - print("Error sending response: \(error)", to: &self.logger) + Logger.automationServer.error("Error sending response: \(error)") } connection.cancel() }) ) } } catch { - print("Got error encoding JSON: \(error)", to: &logger) + Logger.automationServer.error("Got error encoding JSON: \(error)") } } diff --git a/DuckDuckGo/LaunchOptionsHandler.swift b/DuckDuckGo/LaunchOptionsHandler.swift index 246de4dc13..901a58d6bc 100644 --- a/DuckDuckGo/LaunchOptionsHandler.swift +++ b/DuckDuckGo/LaunchOptionsHandler.swift @@ -25,7 +25,7 @@ public final class LaunchOptionsHandler { 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) { @@ -34,7 +34,7 @@ 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 { diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 50866746a3..ff8a897bf7 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -492,8 +492,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 } From f8ec2b4b497e4630540dfe69f7e15e9a08b78161 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 4 Feb 2025 11:18:46 +0000 Subject: [PATCH 04/23] Remove maestro specific changes --- .../browser_features/shared_web_tests.yaml | 31 ------------------- .maestro/run_shared_web_tests.sh | 21 ------------- .maestro/shared/create_bookmarklette.yaml | 21 ------------- .maestro/shared/get-url.yaml | 11 ------- .maestro/shared/set-url.yaml | 13 -------- .maestro/shared/setup.yaml | 1 - .../AppLifecycle/AppStates/Launching.swift | 1 - 7 files changed, 99 deletions(-) delete mode 100644 .maestro/browser_features/shared_web_tests.yaml delete mode 100644 .maestro/run_shared_web_tests.sh delete mode 100644 .maestro/shared/create_bookmarklette.yaml delete mode 100644 .maestro/shared/get-url.yaml delete mode 100644 .maestro/shared/set-url.yaml diff --git a/.maestro/browser_features/shared_web_tests.yaml b/.maestro/browser_features/shared_web_tests.yaml deleted file mode 100644 index 2f15ca6ca4..0000000000 --- a/.maestro/browser_features/shared_web_tests.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# tabs.yaml -appId: com.duckduckgo.mobile.ios -tags: - - release - ---- - -# Set up -- runFlow: - file: ../shared/setup.yaml - -# Load Site -- assertVisible: - id: "searchEntry" -- tapOn: - id: "searchEntry" -- inputText: "https://web-platform.test:9000/tools/runner/index.html" -- pressKey: Enter - -- tapOn: "Start" -- assertVisible: "Download JSON results" -- tapOn: "Download JSON results" -- tapOn: "Save To Downloads" - -- assertVisible: "Passed" -# Do something else to validate we downloaded -#- assertVisible: -# childOf: -# id: "output" -# text: "1" - diff --git a/.maestro/run_shared_web_tests.sh b/.maestro/run_shared_web_tests.sh deleted file mode 100644 index 3565266141..0000000000 --- a/.maestro/run_shared_web_tests.sh +++ /dev/null @@ -1,21 +0,0 @@ -pwd=$(pwd) -source "$pwd/.maestro/run_ui_tests.sh" .maestro/browser_features/shared_web_tests.yaml -app_dir=$(xcrun simctl get_app_container booted $app_bundle data) - -echo "ℹ️ Running shared web tests" - -echo "Ensure runner results were collected" -runner_results_path="$app_dir/Documents/Downloads/runner-results.json" -if [ ! -f "$runner_results_path" ]; then - echo "‼️ No runner results found at $runner_results_path" - exit 1 -fi -echo "✅ Found runner results at $runner_results_path" -tail -n 1 "$runner_results_path" - -echo "ℹ️ Parsing runner results" -runner_results=$(cat "$runner_results_path") -if [ -z "$runner_results" ]; then - echo "‼️ No runner results found" - exit 1 -fi diff --git a/.maestro/shared/create_bookmarklette.yaml b/.maestro/shared/create_bookmarklette.yaml deleted file mode 100644 index 9801c2fccf..0000000000 --- a/.maestro/shared/create_bookmarklette.yaml +++ /dev/null @@ -1,21 +0,0 @@ -appId: com.duckduckgo.mobile.ios ---- - -- tapOn: - id: searchEntry -- inputText: localhost -- pressKey: Enter -- tapOn: Browsing Menu -- tapOn: Add Bookmark -- tapOn: Edit -- tapOn: - id: title -- inputText: ${TITLE} -- tapOn: - id: url -- inputText: ${URL} -- tapOn: Save -- tapOn: Done -- tapOn: Browsing Menu -- tapOn: Bookmarks -- tapOn: ${TITLE} \ No newline at end of file diff --git a/.maestro/shared/get-url.yaml b/.maestro/shared/get-url.yaml deleted file mode 100644 index 4abc761a6e..0000000000 --- a/.maestro/shared/get-url.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# onboarding.yaml - -appId: com.duckduckgo.mobile.ios ---- - -- copyTextFrom: - id: "searchEntry" -- assertVisible: - text: ${maestro.copiedText} -- evalScript: ${console.log('URL text:' + JSON.stringify(maestro.copiedText))} -- evalScript: ${throw new Error('URL text:' + JSON.stringify(maestro.copiedText))} \ No newline at end of file diff --git a/.maestro/shared/set-url.yaml b/.maestro/shared/set-url.yaml deleted file mode 100644 index ece3fff375..0000000000 --- a/.maestro/shared/set-url.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# onboarding.yaml - -appId: com.duckduckgo.mobile.ios ---- - -- tapOn: - id: "searchEntry" -#- longPressOn: -# id: "searchEntry" -#- tapOn: 'Select All' -- eraseText: 1000 -- inputText: ${URL} -- pressKey: Enter \ No newline at end of file diff --git a/.maestro/shared/setup.yaml b/.maestro/shared/setup.yaml index 1c61627b9c..e74e4d3d49 100644 --- a/.maestro/shared/setup.yaml +++ b/.maestro/shared/setup.yaml @@ -15,7 +15,6 @@ appId: com.duckduckgo.mobile.ios isUITesting: true # Renaming `isUITesting` requires to update LaunchOptionsHandler `isUITesting` key isOnboardingCompleted: ${ONBOARDING_COMPLETED} # Renaming `isOnboardingCompleted` requires to update LaunchOptionsHandler `isOnboardingCompleted` key currentAppVariant: ${APP_VARIANT} # Renaming `currentAppVariant` requires to update LaunchOptionsHandler `currentAppVariant` key - automationPort: ${AUTOMATION_PORT} # Get past onboarding screens - runFlow: diff --git a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift index 56be2c3702..079027114a 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift @@ -35,7 +35,6 @@ import Combine import PixelKit import PixelExperimentKit - /// Represents the transient state where the app is being prepared for user interaction after being launched by the system. /// - Usage: /// - This state is typically associated with the `application(_:didFinishLaunchingWithOptions:)` method. From 6549f462e11b554d9a138b74ae3cb8d53123f138 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 4 Feb 2025 12:48:43 +0000 Subject: [PATCH 05/23] Add github workflow --- .github/workflows/ios-shared-web-tests.yml | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/ios-shared-web-tests.yml diff --git a/.github/workflows/ios-shared-web-tests.yml b/.github/workflows/ios-shared-web-tests.yml new file mode 100644 index 0000000000..dc63cff179 --- /dev/null +++ b/.github/workflows/ios-shared-web-tests.yml @@ -0,0 +1,67 @@ +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: Build iOS + run: | + bash .maestro/setup_ui_tests.sh --only-build + + - name: Checkout shared web tests + uses: actions/checkout@v4 + with: + repository: duckduckgo/shared-web-tests + path: ../shared-web-tests + + - name: Build and Test + run: | + cd ../shared-web-tests + npm run build + cd web-driver + cargo build + cd ../build + ./wpt run --product duckduckgo --binary ../webdriver/target/debug/ddgdriver --log-mach - --log-mach-level info duckduckgo + + \ No newline at end of file From 9051f245eed7b6fb34b6ebe70d3e96ce5f9f9486 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 4 Feb 2025 14:03:55 +0000 Subject: [PATCH 06/23] Remove maestro specific setup --- .github/workflows/ios-shared-web-tests.yml | 15 ++++++-- .maestro/common.sh | 29 ++++++++++++++ .maestro/setup_ui_tests.sh | 44 +--------------------- 3 files changed, 42 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ios-shared-web-tests.yml b/.github/workflows/ios-shared-web-tests.yml index dc63cff179..6fa7c0751f 100644 --- a/.github/workflows/ios-shared-web-tests.yml +++ b/.github/workflows/ios-shared-web-tests.yml @@ -47,7 +47,9 @@ jobs: - name: Build iOS run: | - bash .maestro/setup_ui_tests.sh --only-build + source .maestro/common.sh + project_root=$(pwd) + build_app - name: Checkout shared web tests uses: actions/checkout@v4 @@ -61,7 +63,14 @@ jobs: npm run build cd web-driver cargo build - cd ../build - ./wpt run --product duckduckgo --binary ../webdriver/target/debug/ddgdriver --log-mach - --log-mach-level info duckduckgo + + - name: Add CA key + run: | + xcrun simctl keychain booted add-root-cert ../shared-web-tests/web-platform-tests/build/tools/certs/cacert.pem + + - name: Run tests + run: | + cd ../build + ./wpt run --product duckduckgo --binary ../webdriver/target/debug/ddgdriver --log-mach - --log-mach-level info duckduckgo \ 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 0fa5e7f0ec..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() { @@ -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 @@ -83,9 +50,7 @@ while [[ "$#" -gt 0 ]]; do case $1 in --skip-build) skip_build=1 ;; - --only-build) - only_build=1 ;; - --rebuild) + --rebuild) rebuild=1 ;; *) esac @@ -98,11 +63,6 @@ else build_app $rebuild fi -if [ -n "$only_build" ]; then - echo "ℹ️ Only building the app. Exiting." - exit 0 -fi - echo "ℹ️ Closing all simulators" killall Simulator @@ -118,8 +78,6 @@ fi echo "📱 Using simulator $device_uuid" xcrun simctl boot $device_uuid -xcrun simctl keychain booted add-root-cert ../shared-web-tests/web-platform-tests/tools/certs/cacert.pem -xcrun simctl keychain booted add-root-cert ../shared-web-tests/web-platform-tests/build/tools/certs/cacert.pem if [ $? -ne 0 ]; then echo "‼️ Unable to boot simulator" exit 1 From 84677958908f1820de0b14a02904f030f6e8102a Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 4 Feb 2025 14:54:56 +0000 Subject: [PATCH 07/23] Reorder action --- .github/workflows/ios-shared-web-tests.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ios-shared-web-tests.yml b/.github/workflows/ios-shared-web-tests.yml index 6fa7c0751f..40ea457ca7 100644 --- a/.github/workflows/ios-shared-web-tests.yml +++ b/.github/workflows/ios-shared-web-tests.yml @@ -45,17 +45,13 @@ jobs: - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_$(<.xcode-version).app/Contents/Developer - - name: Build iOS - run: | - source .maestro/common.sh - project_root=$(pwd) - build_app - - name: Checkout shared web tests uses: actions/checkout@v4 with: repository: duckduckgo/shared-web-tests - path: ../shared-web-tests + path: shared-web-tests + ref: jkt/webdriver + token: ${{ secrets.GITHUB_TOKEN }} - name: Build and Test run: | @@ -64,6 +60,12 @@ jobs: cd web-driver cargo build + - name: Build iOS + run: | + source .maestro/common.sh + project_root=$(pwd) + build_app + - name: Add CA key run: | xcrun simctl keychain booted add-root-cert ../shared-web-tests/web-platform-tests/build/tools/certs/cacert.pem From f3ab4cc9f6ef5c310b07a972b4e273adcb1781d1 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 6 Feb 2025 00:04:08 +0000 Subject: [PATCH 08/23] Move changes to extensions --- DuckDuckGo-iOS.xcodeproj/project.pbxproj | 12 +++++- .../MainViewController+Automation.swift | 34 +++++++++++++++ DuckDuckGo/MainViewController.swift | 10 ----- DuckDuckGo/TabViewController+Automation.swift | 42 +++++++++++++++++++ DuckDuckGo/TabViewController.swift | 20 --------- 5 files changed, 86 insertions(+), 32 deletions(-) create mode 100644 DuckDuckGo/MainViewController+Automation.swift create mode 100644 DuckDuckGo/TabViewController+Automation.swift diff --git a/DuckDuckGo-iOS.xcodeproj/project.pbxproj b/DuckDuckGo-iOS.xcodeproj/project.pbxproj index 186bb2b1e9..deaa40a150 100644 --- a/DuckDuckGo-iOS.xcodeproj/project.pbxproj +++ b/DuckDuckGo-iOS.xcodeproj/project.pbxproj @@ -421,7 +421,6 @@ 7B1604E82CB685B400A44EC6 /* Logger+TipKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */; }; 7B1604EC2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */; }; 7B1604EE2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604ED2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift */; }; - 7B70CC3C2D15C0BC0096A1C6 /* AutomationServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B70CC3B2D15C0BC0096A1C6 /* AutomationServer.swift */; }; 7B1681012D106CB9005EAE24 /* UserTextShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1681002D106CB4005EAE24 /* UserTextShared.swift */; }; 7B1681022D106CCC005EAE24 /* UserTextShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1681002D106CB4005EAE24 /* UserTextShared.swift */; }; 7B1681062D10BC96005EAE24 /* VPNShortcutIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1681042D10BC7B005EAE24 /* VPNShortcutIntents.swift */; }; @@ -440,7 +439,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 */; }; @@ -1832,7 +1834,6 @@ 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+TipKit.swift"; sourceTree = ""; }; 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TipKitController+ConvenienceInitializers.swift"; sourceTree = ""; }; 7B1604ED2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipKitDebugOptionsUIActionHandling.swift; sourceTree = ""; }; - 7B70CC3B2D15C0BC0096A1C6 /* AutomationServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationServer.swift; sourceTree = ""; }; 7B1681002D106CB4005EAE24 /* UserTextShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTextShared.swift; sourceTree = ""; }; 7B1681042D10BC7B005EAE24 /* VPNShortcutIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNShortcutIntents.swift; sourceTree = ""; }; 7B16810B2D10CF44005EAE24 /* WidgetVPNIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetVPNIntents.swift; sourceTree = ""; }; @@ -1844,7 +1845,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 = ""; }; @@ -6378,6 +6382,7 @@ 984147C224F026A300362052 /* Tab.storyboard */, F1386BA31E6846C40062FC3C /* TabDelegate.swift */, F159BDA31F0BDB5A00B4A01D /* TabViewController.swift */, + 7B9D53302D54321200D9E937 /* TabViewController+Automation.swift */, CBC88EE42C8097B500F0F8C5 /* URLCredentialCreator.swift */, CB2A7EEE283D185100885F67 /* RulesCompilationMonitor.swift */, 9820EAF422613CD30089094D /* WebProgressWorker.swift */, @@ -6778,6 +6783,7 @@ 8577A1C4255D2C0D00D43FCD /* HitTestingToolbar.swift */, 85DDE03F2AC6FF65006ABCA2 /* MainView.swift */, F17669D61E43401C003D3222 /* MainViewController.swift */, + 7B9D532E2D5431E400D9E937 /* MainViewController+Automation.swift */, 566B736F2BECD46800FF1959 /* MainViewController+SyncAlerts.swift */, 981CA7E92617797500E119D5 /* MainViewController+AddFavoriteFlow.swift */, 1E4F4A59297193DE00625985 /* MainViewController+CookiesManaged.swift */, @@ -7960,6 +7966,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 */, 7BFD5FD72C9DB9D7000FF959 /* VPNGeoswitchingTip.swift in Sources */, F1668BCE1E798081008CBA04 /* BookmarksViewController.swift in Sources */, @@ -8289,6 +8296,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/MainViewController+Automation.swift b/DuckDuckGo/MainViewController+Automation.swift new file mode 100644 index 0000000000..1762452c61 --- /dev/null +++ b/DuckDuckGo/MainViewController+Automation.swift @@ -0,0 +1,34 @@ +// +// 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 ! + } + + func currentUrl() -> String? { + return currentTab?.getUrl() + } + +} diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 06bc845925..f070f1549e 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -1015,16 +1015,6 @@ class MainViewController: UIViewController { currentTab?.executeBookmarklet(url: url) } } - - func executeScript(_ javaScriptString: String, - args: [String: Any] = [:]) async -> Result? { - var result = await currentTab?.executeScript(javaScriptString, args: args) - return result! // TODO fix ! - } - - func currentUrl() -> String? { - return currentTab?.getUrl() - } private func loadBackForwardItem(_ item: WKBackForwardListItem) { prepareTabForRequest { diff --git a/DuckDuckGo/TabViewController+Automation.swift b/DuckDuckGo/TabViewController+Automation.swift new file mode 100644 index 0000000000..6919fcbb79 --- /dev/null +++ b/DuckDuckGo/TabViewController+Automation.swift @@ -0,0 +1,42 @@ +// +// 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) + } + } + + public func getUrl() -> String? { + return webView.url?.absoluteString + } + +} diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index a94f8b3652..d814f06e78 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -737,26 +737,6 @@ class TabViewController: UIViewController { } } - @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 // .defaultClient, - ) - return .success(result) - } catch { - return .failure(error) - } - } - - public func getUrl() -> String? { - return webView.url?.absoluteString - } - public func load(url: URL) { wasLoadingStoppedExternally = false webView.stopLoading() From 3c11508ae8a834bf11370e85cb5d557f9da3ca47 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 6 Feb 2025 01:18:34 +0000 Subject: [PATCH 09/23] Separate out automation steps into their own methods --- DuckDuckGo/AutomationServer.swift | 259 ++++++++++++++++-------------- 1 file changed, 138 insertions(+), 121 deletions(-) diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift index 94e26630a2..80ca8dbb83 100644 --- a/DuckDuckGo/AutomationServer.swift +++ b/DuckDuckGo/AutomationServer.swift @@ -23,20 +23,6 @@ extension Logger { static var automationServer = { Logger(subsystem: Bundle.main.bundleIdentifier ?? "DuckDuckGo", category: "Automation Server") }() } -struct Log: TextOutputStream { - - func write(_ string: String) { - let fm = FileManager.default - let log = fm.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("log-automation.txt") - if let handle = try? FileHandle(forWritingTo: log) { - handle.seekToEndOfFile() - handle.write(string.data(using: .utf8)!) - handle.closeFile() - } else { - try? string.data(using: .utf8)?.write(to: log) - } - } -} class AutomationServer { let listener: NWListener @@ -45,9 +31,7 @@ class AutomationServer { init(main: MainViewController, port: Int?) { var port = port ?? 8786 self.main = main - var log = Log() - print("Starting automation server on port \(port)", to: &log) - print("Bundle: \(Bundle.main.bundleIdentifier)", to: &log) + Logger.automationServer.info("Starting automation server on port \(port)") listener = try! NWListener(using: .tcp, on: NWEndpoint.Port(integerLiteral: UInt16(port))) listener.newConnectionHandler = handleConnection listener.start(queue: .main) @@ -72,12 +56,12 @@ class AutomationServer { 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 { @@ -105,6 +89,10 @@ class AutomationServer { 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!") @@ -113,11 +101,7 @@ class AutomationServer { if let firstLine = stringContent.components(separatedBy: CharacterSet.newlines).first { Logger.automationServer.info("First line: \(firstLine)") } - - func getQueryStringParameter(url: String, param: String) -> String? { - guard let url = URLComponents(string: url) else { return nil } - return url.queryItems?.first(where: { $0.name == param })?.value - } + // Get url parameter from path // GET / HTTP/1.1 if #available(iOS 16.0, *) { @@ -125,116 +109,149 @@ class AutomationServer { if let match = stringContent.firstMatch(of: path) { Logger.automationServer.info("Path: \(match.2)") // Convert the path into a URL object - guard let url = URL(string: String(match.2)) else { - print("Invalid URL: \(match.2)") + guard let url = URLComponents(string: String(match.2)) else { + Logger.automationServer.error("Invalid URL: \(match.2)") return // Or handle the error appropriately } - if url.path == "/navigate" { - let navigateUrlString = getQueryStringParameter(url: String(match.2), param: "url") ?? "" - let navigateUrl = URL(string: navigateUrlString)! - self.main.loadUrl(navigateUrl) - self.respond(on: connection, response: "done") - } else if url.path == "/execute" { - let script = getQueryStringParameter(url: String(match.2), param: "script") ?? "" - var args: [String: String] = [:] - // json decode args - if let argsString = getQueryStringParameter(url: String(match.2), param: "args") { - if let argsData = argsString.data(using: .utf8) { - do { - let jsonDecoder = JSONDecoder() - args = try jsonDecoder.decode([String: String].self, from: argsData) - } catch { - self.respond(on: connection, response: "{\"error\": \"\(error.localizedDescription)\", \"args\": \"\(argsString)\"}") - } - } else { - self.respond(on: connection, response: "{\"error\": \"Unable to decode args\"}") - } - } - Task { - await self.executeScript(script, args: args, on: connection) - } - } else if url.path == "/getUrl" { + switch url.path { + case "/navigate": + self.navigate(on: connection, url: url) + case "/execute": + self.execute(on: connection, url: url) + case "/getUrl": self.respond(on: connection, response: self.main.currentUrl() ?? "") - } else if url.path == "/getWindowHandles" { - // TODO get all tabs - let handle = self.main.tabManager.current(createIfNeeded: true) - guard let handle else { - self.respond(on: connection, response: "no window") - return - } - - let handles = self.main.tabManager.model.tabs.map({ tab in - let tabView = self.main.tabManager.controller(for: tab)! - return String(UInt(bitPattern: ObjectIdentifier(tabView))) - }) - - 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.respond(on: connection, response: "{\"error\":\"Failed to encode response\"}") - } - } else if url.path == "/closeWindow" { - self.main.closeTab(self.main.currentTab!.tabModel) - self.respond(on: connection, response: "{\"success\":true}") - } else if url.path == "/switchToWindow" { - if let handleString = getQueryStringParameter(url: String(match.2), 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 String(UInt(bitPattern: ObjectIdentifier(tabView))) == handleString - }) { - Logger.automationServer.info("found tab \(tabIndex)") - self.main.tabManager.select(tabAt: tabIndex) - self.respond(on: connection, response: "{\"success\":true}") - } else { - self.respond(on: connection, response: "{\"error\":\"Invalid window handle\"}") - } - } else { - self.respond(on: connection, response: "{\"error\":\"Invalid window handle\"}") - } - } else if url.path == "/newWindow" { - self.main.newTab() - let handle = self.main.tabManager.current(createIfNeeded: true) - guard let handle else { - self.respond(on: connection, response: "no window") - return - } - // Response {handle: "", type: "tab"} - let response: [String: String] = ["handle": String(UInt(bitPattern: ObjectIdentifier(handle))), "type": "tab"] - if let jsonData = try? JSONEncoder().encode(response), - let jsonString = String(data: jsonData, encoding: .utf8) { - self.respond(on: connection, response: jsonString) - } else { - self.respond(on: connection, response: "{\"error\":\"Failed to encode response\"}") - } - } else if url.path == "/getWindowHandle" { - let handle = self.main.currentTab - guard let handle else { - self.respond(on: connection, response: "no window") - return - } - self.respond(on: connection, response: String(UInt(bitPattern: ObjectIdentifier(handle)))) - } else { - self.respond(on: connection, response: "unknown") + 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") } } else { - self.respond(on: connection, response: "unknown method") + self.respondError(on: connection, error: "unknown method") } } else { - self.respond(on: connection, response: "unhandled") + self.respondError(on: connection, error: "unhandled") + } + } + + @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 let argsString = getQueryStringParameter(url: url, param: "args") { + if let argsData = argsString.data(using: .utf8) { + do { + let jsonDecoder = JSONDecoder() + args = try jsonDecoder.decode([String: String].self, from: argsData) + } catch { + self.respondError(on: connection, error: error.localizedDescription) + } + } else { + self.respondError(on: connection, error: "Unable to decode args") + } } + 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: String(UInt(bitPattern: ObjectIdentifier(handle)))) + } + + @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 String(UInt(bitPattern: ObjectIdentifier(tabView))) + }) + + 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 String(UInt(bitPattern: ObjectIdentifier(tabView))) == 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": String(UInt(bitPattern: ObjectIdentifier(handle))), "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)") - var result = await main.executeScript(script, args: args) + let result = await main.executeScript(script, args: args) Logger.automationServer.info("Have result to execute script: \(String(describing: result))") - guard var result else { + guard let result else { return } do { From 463ab99de5fab23789a69a64fee7c43b9bb1b92a Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 6 Feb 2025 10:27:36 +0000 Subject: [PATCH 10/23] Remove debug if statements --- DuckDuckGo/AppLifecycle/AppStates/Launching.swift | 2 -- DuckDuckGo/AutomationServer.swift | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift index 2c58b85c65..b3a96e52d4 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift @@ -653,12 +653,10 @@ struct Launching: AppState { } private func startAutomationServer() { -//#if DEBUG let launchOptionsHandler = LaunchOptionsHandler() if launchOptionsHandler.isUITesting && launchOptionsHandler.automationPort != nil { AutomationServer(main: mainViewController!, port: launchOptionsHandler.automationPort) } -//#endif } } diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift index 80ca8dbb83..3995e9166c 100644 --- a/DuckDuckGo/AutomationServer.swift +++ b/DuckDuckGo/AutomationServer.swift @@ -1,8 +1,8 @@ // -// AppDelegate.swift +// AutomationServer.swift // DuckDuckGo // -// Copyright © 2024 DuckDuckGo. All rights reserved. +// 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. From d46f947c609b6c0c931903b50358bc496fa030f5 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 6 Feb 2025 15:32:53 +0000 Subject: [PATCH 11/23] Remove force try in automation server --- DuckDuckGo/AutomationServer.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift index 3995e9166c..61b8fcf76c 100644 --- a/DuckDuckGo/AutomationServer.swift +++ b/DuckDuckGo/AutomationServer.swift @@ -32,7 +32,12 @@ class AutomationServer { var port = port ?? 8786 self.main = main Logger.automationServer.info("Starting automation server on port \(port)") - listener = try! NWListener(using: .tcp, on: NWEndpoint.Port(integerLiteral: UInt16(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 From 8f82b7dc1f704de1bc58a3a23676ff510354c2ed Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 11 Feb 2025 11:31:36 +0000 Subject: [PATCH 12/23] Rename startAutomationServer to startAutomationServerIfNeeded --- DuckDuckGo/AppLifecycle/AppStates/Launching.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift index b3a96e52d4..1a31955a2a 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift @@ -540,7 +540,7 @@ struct Launching: AppState { // Register Malicious Site Protection background tasks to fetch datasets maliciousSiteProtectionService.onLaunching() - startAutomationServer() + startAutomationServerIfNeeded() } private var appDependencies: AppDependencies { @@ -652,7 +652,7 @@ struct Launching: AppState { dataStoreIDManager: dataStoreIDManager) } - private func startAutomationServer() { + private func startAutomationServerIfNeeded() { let launchOptionsHandler = LaunchOptionsHandler() if launchOptionsHandler.isUITesting && launchOptionsHandler.automationPort != nil { AutomationServer(main: mainViewController!, port: launchOptionsHandler.automationPort) From f05c8d7bb61631e7f4abf6a17ee3adc13536b952 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 11 Feb 2025 12:02:32 +0000 Subject: [PATCH 13/23] Remove making controller public --- DuckDuckGo/TabManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/TabManager.swift b/DuckDuckGo/TabManager.swift index a554928b38..d381c05197 100644 --- a/DuckDuckGo/TabManager.swift +++ b/DuckDuckGo/TabManager.swift @@ -162,7 +162,7 @@ class TabManager { } } - public func controller(for tab: Tab) -> TabViewController? { + func controller(for tab: Tab) -> TabViewController? { return tabControllerCache.first { $0.tabModel === tab } } From 93ac4df79a2a6e8f0b459dd3aab513032c46a91b Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 11 Feb 2025 12:12:13 +0000 Subject: [PATCH 14/23] Make AutomationServer final --- DuckDuckGo/AutomationServer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift index 61b8fcf76c..fcf0611271 100644 --- a/DuckDuckGo/AutomationServer.swift +++ b/DuckDuckGo/AutomationServer.swift @@ -24,7 +24,7 @@ extension Logger { } -class AutomationServer { +final class AutomationServer { let listener: NWListener let main: MainViewController From 7b00a95501eac42738305ab39691fcf6bb4c7a75 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 11 Feb 2025 12:14:17 +0000 Subject: [PATCH 15/23] Remove stray commented out global connection handler for Automation --- DuckDuckGo/AutomationServer.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift index fcf0611271..d653b0ca22 100644 --- a/DuckDuckGo/AutomationServer.swift +++ b/DuckDuckGo/AutomationServer.swift @@ -333,7 +333,6 @@ final class AutomationServer { @MainActor func handleConnection(_ connection: NWConnection) { - // connection.start(queue: .global()) connection.start(queue: .main) self.receive(from: connection) } From cbcd86fad010c91e49aea76d6acdc61d3867cd41 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 11 Feb 2025 12:24:06 +0000 Subject: [PATCH 16/23] Move currentURL method into direct access within the server --- DuckDuckGo/AutomationServer.swift | 3 ++- DuckDuckGo/MainViewController+Automation.swift | 4 ---- DuckDuckGo/TabViewController+Automation.swift | 4 ---- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift index d653b0ca22..44abdfa6cc 100644 --- a/DuckDuckGo/AutomationServer.swift +++ b/DuckDuckGo/AutomationServer.swift @@ -124,7 +124,8 @@ final class AutomationServer { case "/execute": self.execute(on: connection, url: url) case "/getUrl": - self.respond(on: connection, response: self.main.currentUrl() ?? "") + let currentUrl = self.main.currentTab?.webView.url?.absoluteString + self.respond(on: connection, response: currentUrl ?? "") case "/getWindowHandles": self.getWindowHandles(on: connection, url: url) case "/closeWindow": diff --git a/DuckDuckGo/MainViewController+Automation.swift b/DuckDuckGo/MainViewController+Automation.swift index 1762452c61..391cae8181 100644 --- a/DuckDuckGo/MainViewController+Automation.swift +++ b/DuckDuckGo/MainViewController+Automation.swift @@ -27,8 +27,4 @@ extension MainViewController { return result! // TODO fix ! } - func currentUrl() -> String? { - return currentTab?.getUrl() - } - } diff --git a/DuckDuckGo/TabViewController+Automation.swift b/DuckDuckGo/TabViewController+Automation.swift index 6919fcbb79..f9a350bd16 100644 --- a/DuckDuckGo/TabViewController+Automation.swift +++ b/DuckDuckGo/TabViewController+Automation.swift @@ -35,8 +35,4 @@ extension TabViewController { } } - public func getUrl() -> String? { - return webView.url?.absoluteString - } - } From e76cd77786725c655c5707b708805ae9aed6993f Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 11 Feb 2025 12:39:16 +0000 Subject: [PATCH 17/23] Reduce nesting in AutomationServer --- DuckDuckGo/AutomationServer.swift | 72 ++++++++++++++++--------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift index 44abdfa6cc..5a848b5133 100644 --- a/DuckDuckGo/AutomationServer.swift +++ b/DuckDuckGo/AutomationServer.swift @@ -107,43 +107,45 @@ final class AutomationServer { 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 - if #available(iOS 16.0, *) { - let path = /^(GET|POST) (\/[^ ]*) HTTP/ - if let match = stringContent.firstMatch(of: path) { - 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") - } - } else { - self.respondError(on: connection, error: "unknown method") - } - } else { - self.respondError(on: connection, error: "unhandled") + 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") } } From 4313f268554913cf7b4f3cb10075616e8d0afefe Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 11 Feb 2025 12:42:31 +0000 Subject: [PATCH 18/23] Return early by using guards for error states --- DuckDuckGo/AutomationServer.swift | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift index 5a848b5133..1c0c6255d5 100644 --- a/DuckDuckGo/AutomationServer.swift +++ b/DuckDuckGo/AutomationServer.swift @@ -161,17 +161,18 @@ final class AutomationServer { func execute(on connection: NWConnection, url: URLComponents) { let script = getQueryStringParameter(url: url, param: "script") ?? "" var args: [String: String] = [:] - // json decode args + // json decode args if present if let argsString = getQueryStringParameter(url: url, param: "args") { - if let argsData = argsString.data(using: .utf8) { - do { - let jsonDecoder = JSONDecoder() - args = try jsonDecoder.decode([String: String].self, from: argsData) - } catch { - self.respondError(on: connection, error: error.localizedDescription) - } - } else { + 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 { @@ -198,7 +199,7 @@ final class AutomationServer { if let jsonData = try? JSONEncoder().encode(handles), let jsonString = String(data: jsonData, encoding: .utf8) { - self.respond(on: connection, response: jsonString) + self.respond(on: connection, response: jsonString) } else { // Handle JSON encoding failure self.respondError(on: connection, error: "Failed to encode response") From 2fd77ecc4f1f3c53efaf1a8499709c1c3bb2404e Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Tue, 11 Feb 2025 12:55:49 +0000 Subject: [PATCH 19/23] Switch object identifiers to use tabModel.uid --- DuckDuckGo/AutomationServer.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/DuckDuckGo/AutomationServer.swift b/DuckDuckGo/AutomationServer.swift index 1c0c6255d5..ffbd7d380b 100644 --- a/DuckDuckGo/AutomationServer.swift +++ b/DuckDuckGo/AutomationServer.swift @@ -187,14 +187,14 @@ final class AutomationServer { self.respondError(on: connection, error: "no window") return } - self.respond(on: connection, response: String(UInt(bitPattern: ObjectIdentifier(handle)))) + 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 String(UInt(bitPattern: ObjectIdentifier(tabView))) + return tabView.tabModel.uid }) if let jsonData = try? JSONEncoder().encode(handles), @@ -221,7 +221,7 @@ final class AutomationServer { guard let tabView = self.main.tabManager.controller(for: tab) else { return false } - return String(UInt(bitPattern: ObjectIdentifier(tabView))) == handleString + return tabView.tabModel.uid == handleString }) { Logger.automationServer.info("found tab \(tabIndex)") self.main.tabManager.select(tabAt: tabIndex) @@ -243,7 +243,7 @@ final class AutomationServer { return } // Response {handle: "", type: "tab"} - let response: [String: String] = ["handle": String(UInt(bitPattern: ObjectIdentifier(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) From 6d71981e157a6348ba97f153d85272cfbdef5363 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Wed, 12 Feb 2025 17:40:52 +0000 Subject: [PATCH 20/23] Automation fixes --- .github/workflows/ios-shared-web-tests.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios-shared-web-tests.yml b/.github/workflows/ios-shared-web-tests.yml index 40ea457ca7..d1571c0936 100644 --- a/.github/workflows/ios-shared-web-tests.yml +++ b/.github/workflows/ios-shared-web-tests.yml @@ -48,6 +48,7 @@ jobs: - name: Checkout shared web tests uses: actions/checkout@v4 with: + submodules: recursive repository: duckduckgo/shared-web-tests path: shared-web-tests ref: jkt/webdriver @@ -55,9 +56,9 @@ jobs: - name: Build and Test run: | - cd ../shared-web-tests + cd shared-web-tests npm run build - cd web-driver + cd webdriver cargo build - name: Build iOS From ef7df0c39fce11a99e4fd03a6a2025142b7e4904 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 13 Feb 2025 15:04:09 +0000 Subject: [PATCH 21/23] Get mainViewController in automation server --- DuckDuckGo/AppLifecycle/AppStates/Launching.swift | 8 ++++++-- DuckDuckGo/TabViewController+Automation.swift | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift index 5668eb7bc1..e25bba162e 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Launching.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/Launching.swift @@ -183,9 +183,13 @@ struct Launching: AppState { private func startAutomationServerIfNeeded() { let launchOptionsHandler = LaunchOptionsHandler() - if launchOptionsHandler.isUITesting && launchOptionsHandler.automationPort != nil { - AutomationServer(main: mainViewController!, port: launchOptionsHandler.automationPort) + guard launchOptionsHandler.isUITesting && launchOptionsHandler.automationPort != nil else { + return } + guard let rootViewController = window.rootViewController as? MainViewController else { + return + } + AutomationServer(main: rootViewController, port: launchOptionsHandler.automationPort) } } diff --git a/DuckDuckGo/TabViewController+Automation.swift b/DuckDuckGo/TabViewController+Automation.swift index f9a350bd16..1d6c361bbb 100644 --- a/DuckDuckGo/TabViewController+Automation.swift +++ b/DuckDuckGo/TabViewController+Automation.swift @@ -28,7 +28,7 @@ extension TabViewController { arguments: args, in: nil, contentWorld: .page - ) + ) ?? "" return .success(result) } catch { return .failure(error) From 2b558e926c843fc417c1637e904aced278e51a7f Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 13 Feb 2025 16:36:59 +0000 Subject: [PATCH 22/23] Simplify configuration of tests --- .github/workflows/ios-shared-web-tests.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/ios-shared-web-tests.yml b/.github/workflows/ios-shared-web-tests.yml index d1571c0936..e4b17d0f39 100644 --- a/.github/workflows/ios-shared-web-tests.yml +++ b/.github/workflows/ios-shared-web-tests.yml @@ -58,8 +58,6 @@ jobs: run: | cd shared-web-tests npm run build - cd webdriver - cargo build - name: Build iOS run: | @@ -67,13 +65,9 @@ jobs: project_root=$(pwd) build_app - - name: Add CA key - run: | - xcrun simctl keychain booted add-root-cert ../shared-web-tests/web-platform-tests/build/tools/certs/cacert.pem - - name: Run tests run: | - cd ../build + cd shared-web-tests/build ./wpt run --product duckduckgo --binary ../webdriver/target/debug/ddgdriver --log-mach - --log-mach-level info duckduckgo \ No newline at end of file From af83c263e6898f0267a99f166f58a1c789a76abc Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Fri, 14 Feb 2025 00:24:22 +0000 Subject: [PATCH 23/23] Move testing steps to npm --- .github/workflows/ios-shared-web-tests.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios-shared-web-tests.yml b/.github/workflows/ios-shared-web-tests.yml index e4b17d0f39..8f3e068549 100644 --- a/.github/workflows/ios-shared-web-tests.yml +++ b/.github/workflows/ios-shared-web-tests.yml @@ -58,6 +58,7 @@ jobs: run: | cd shared-web-tests npm run build + sudo npm run install-hosts - name: Build iOS run: | @@ -67,7 +68,7 @@ jobs: - name: Run tests run: | - cd shared-web-tests/build - ./wpt run --product duckduckgo --binary ../webdriver/target/debug/ddgdriver --log-mach - --log-mach-level info duckduckgo + cd shared-web-tests + npm run test \ No newline at end of file