blob: 409450ce83c6bfc77465b42a6ca398380eba5f6f [file] [edit]
// Copyright (C) 2026 Apple Inc. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions
// are met:
// 1. Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// 2. Redistributions in binary form must reproduce the above copyright
// notice, this list of conditions and the following disclaimer in the
// documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
// BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
// THE POSSIBILITY OF SUCH DAMAGE.
// The Swift counterparts of SiteIsolationTestUtilities.h and the helpers in SiteIsolation.mm, for site isolation tests
// written in Swift.
public import CoreGraphics
import Foundation
public import TestWebKitAPILibrary.Helpers.cocoa.TestNavigationDelegate
public import TestWebKitAPILibrary.Helpers.cocoa.TestWKWebView
public import WebKit
private import WebKit_Private.WKFrameInfoPrivate
private import WebKit_Private.WKPreferencesPrivate
public import WebKit_Private.WKWebViewPrivate
private import WebKit_Private.WKWebsiteDataStorePrivate
private import WebKit_Private._WKFeature
private import WebKit_Private._WKFrameTreeNode
private import WebKit_Private._WKWebsiteDataStoreConfiguration
import struct Foundation.URL
import struct Swift.String
extension WKWebViewConfiguration {
/// Creates a configuration whose website data store sends HTTPS loads through a server's proxy.
///
/// The server must use the `.httpsProxy` protocol. Loads from every `https` host then reach the server, so a
/// test can use several sites, such as `https://example.com` and `https://webkit.org`. The server routes
/// requests by path alone, so each route needs a path that's distinct across all of those sites.
///
/// - Parameter server: The configuration of the server.
public convenience init(httpsProxyFor server: HTTPServer.Configuration) {
self.init()
let storeConfiguration = _WKWebsiteDataStoreConfiguration(nonPersistentConfiguration: ())
storeConfiguration.httpsProxy = server.httpsProxy
websiteDataStore = WKWebsiteDataStore._store(with: storeConfiguration)
}
/// Turns an experimental feature on or off for the web views created with the configuration.
///
/// - Parameters:
/// - key: The key of the feature, such as `IPCTestingAPIEnabled`.
/// - enabled: Whether to turn the feature on.
public func setFeature(_ key: String, enabled: Bool) {
guard let feature = WKPreferences._features().first(where: { $0.key == key }) else {
fatalError("No experimental feature has the key \"\(key)\"")
}
preferences._setEnabled(enabled, for: feature)
}
}
/// Creates a web view with site isolation enabled, and a navigation delegate that trusts any server certificate.
///
/// A web view only holds its navigation delegate weakly, so keep the delegate alive for as long as the web view
/// loads anything.
///
/// - Parameters:
/// - configuration: The configuration for the web view, such as one created with
/// ``WebKit/WKWebViewConfiguration/init(httpsProxyFor:)``. This enables site isolation in its preferences.
/// - frame: The frame of the web view.
/// - Returns: The web view and its navigation delegate.
@MainActor
public func siteIsolatedWebViewAndDelegate(
configuration: WKWebViewConfiguration,
frame: CGRect = .zero
) -> (webView: TestWKWebView, navigationDelegate: TestNavigationDelegate) {
webViewAndDelegate(configuration: configuration, frame: frame, siteIsolationEnabled: true)
}
/// Creates a web view with site isolation enabled or disabled, and a navigation delegate that trusts any server
/// certificate.
///
/// Use this to run the same test with and without site isolation. A test that only needs site isolation can use
/// ``siteIsolatedWebViewAndDelegate(configuration:frame:)`` instead.
///
/// A web view only holds its navigation delegate weakly, so keep the delegate alive for as long as the web view
/// loads anything.
///
/// - Parameters:
/// - configuration: The configuration for the web view, such as one created with
/// ``WebKit/WKWebViewConfiguration/init(httpsProxyFor:)``. This sets whether site isolation is enabled in its
/// preferences.
/// - frame: The frame of the web view.
/// - siteIsolationEnabled: Whether to enable site isolation.
/// - Returns: The web view and its navigation delegate.
@MainActor
public func webViewAndDelegate(
configuration: WKWebViewConfiguration,
frame: CGRect = .zero,
siteIsolationEnabled: Bool
) -> (webView: TestWKWebView, navigationDelegate: TestNavigationDelegate) {
configuration.preferences._siteIsolationEnabled = siteIsolationEnabled
let navigationDelegate = TestNavigationDelegate()
navigationDelegate.allowAnyTLSCertificate()
let webView = TestWKWebView(frame: frame, configuration: configuration)
webView.navigationDelegate = navigationDelegate
return (webView, navigationDelegate)
}
/// A site-isolated web view that has loaded a page, and focused the cross-origin iframe in it.
@MainActor
public struct WebViewWithFocusedCrossOriginIframe {
/// Markup for a main frame with some text and a 400x300 cross-origin iframe with ID `iframe`, loaded from
/// `https://webkit.org/iframe`.
public static let mainFrameHTML =
"<body style='margin: 0'>main frame text"
+ "<iframe id='iframe' style='width: 400px; height: 300px; border: none;' src='https://webkit.org/iframe'></iframe></body>"
/// The web view, which is 800x600.
public let webView: TestWKWebView
/// The navigation delegate of the web view, which trusts any server certificate.
public let navigationDelegate: TestNavigationDelegate
/// The focused iframe.
public let childFrame: WKFrameInfo
// swift-format-ignore: NeverForceUnwrap
/// Loads `https://example.com/mainframe` in a site-isolated web view, then focuses its first child frame.
///
/// The first child frame must be an iframe with ID `iframe`, as in ``mainFrameHTML``.
///
/// - Parameter configuration: The configuration for the web view, which must send HTTPS loads to the test's
/// server, such as one created with ``WebKit/WKWebViewConfiguration/init(httpsProxyFor:)``.
/// - Throws: The navigation error if the page fails to load, or ``ConditionTimedOut`` if the iframe doesn't get
/// focus.
public init(configuration: WKWebViewConfiguration) async throws {
let (webView, navigationDelegate) = siteIsolatedWebViewAndDelegate(
configuration: configuration,
frame: CGRect(x: 0, y: 0, width: 800, height: 600)
)
// Well formed, so this cannot fail.
webView.load(URLRequest(url: URL(string: "https://example.com/mainframe")!))
try await navigationDelegate.waitForDidFinishNavigation()
await webView.nextPresentationUpdate()
#if WTF_PLATFORM_IOS_FAMILY
webView.focusInWindow()
#endif
try await webView.callJavaScript { "document.getElementById('iframe').focus()" }
// Frame infos are snapshots, so get a new one each time to see whether the iframe has focus yet.
var childFrame: WKFrameInfo?
try await waitForCondition("the cross-origin iframe to have focus") {
childFrame = await webView.firstChildFrame
return childFrame?._isFocused ?? false
}
self.webView = webView
self.navigationDelegate = navigationDelegate
// waitForCondition only returns once childFrame is a focused frame.
self.childFrame = childFrame!
}
}
extension WKWebView {
/// Runs a script that changes the selection in a frame, then waits for the selection attributes of the web view
/// to reflect the change.
///
/// Some commands check the UI process's editor state before sending anything to a web process, so they need it
/// to be up to date with the selection.
///
/// - Parameters:
/// - frame: The frame to run the script in.
/// - script: A script that changes the selection, such as `getSelection().selectAllChildren(document.body)`.
/// - expectedSelection: The kind of selection the script makes.
/// - Throws: Any error raised while running the script, or ``ConditionTimedOut`` if the selection attributes don't
/// come to include `expectedSelection`.
public func setSelection(
in frame: WKFrameInfo,
script: String,
expecting expectedSelection: _WKSelectionAttributes
) async throws {
try await callJavaScript(in: frame) { script }
try await waitForCondition("the selection attributes to include \(expectedSelection)") {
_selectionAttributes.contains(expectedSelection)
}
}
/// The first child frame of the main frame, or `nil` if the main frame has no child frames.
public var firstChildFrame: WKFrameInfo? {
get async {
let mainFrame: _WKFrameTreeNode? = await _frames()
return mainFrame?.childFrames.first?.info
}
}
}
// MARK: Frame trees
/// A frame and its descendants, as one web process sees them.
///
/// With site isolation, each web process that hosts part of a page has its own frame tree for the page. A frame is
/// local in the process that hosts its document, and remote in every other process. Compare the trees of every process
/// with the trees a test expects:
///
/// ```swift
/// #expect(await webView.frameTreesInProcesses() == [
/// .local("https://example.com", children: [.remote]),
/// .remote(children: [.local("https://webkit.org")]),
/// ])
/// ```
public struct FrameTree: Sendable, Equatable, CustomStringConvertible {
/// The origin of the frame, such as `https://example.com`, or `nil` if the frame is remote.
public let origin: String?
/// The child frames of the frame.
///
/// Their order doesn't matter when comparing trees.
public let children: [FrameTree]
/// The identifier of the process the tree comes from, or `nil` for a tree that a test expects.
///
/// Comparing trees ignores this.
public let processIdentifier: pid_t?
/// A remote frame without child frames.
public static let remote = FrameTree.remote(children: [])
private init(origin: String?, children: [FrameTree], processIdentifier: pid_t?) {
self.origin = origin
self.children = children
self.processIdentifier = processIdentifier
}
@MainActor
fileprivate init(_ node: _WKFrameTreeNode) {
// _WKFrameTreeNode.h has no nullability annotations, but every node has frame info.
let info: WKFrameInfo = node.info
let origin: String?
if info._isLocalFrame {
let securityOrigin = info.securityOrigin
let port = securityOrigin.port != 0 ? ":\(securityOrigin.port)" : ""
origin = "\(securityOrigin.protocol)://\(securityOrigin.host)\(port)"
} else {
origin = nil
}
self.init(origin: origin, children: node.childFrames.map(FrameTree.init), processIdentifier: info._processIdentifier)
}
/// Creates a frame that is local to the process.
///
/// - Parameters:
/// - origin: The origin of the frame, such as `https://example.com`.
/// - children: The child frames of the frame.
/// - Returns: The frame.
public static func local(_ origin: String, children: [FrameTree] = []) -> FrameTree {
FrameTree(origin: origin, children: children, processIdentifier: nil)
}
/// Creates a frame that is remote in the process, because another process hosts its document.
///
/// - Parameter children: The child frames of the frame.
/// - Returns: The frame.
public static func remote(children: [FrameTree]) -> FrameTree {
FrameTree(origin: nil, children: children, processIdentifier: nil)
}
// Compare the children as multisets rather than sets: their order doesn't matter, but sibling frames are often
// identical, such as the remote frames for two iframes from the same site.
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
public static func == (lhs: FrameTree, rhs: FrameTree) -> Bool {
lhs.origin == rhs.origin && lhs.children.hasSameElementsInAnyOrder(as: rhs.children)
}
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
public var description: String {
var result = origin ?? "remote"
if let processIdentifier {
result += " (pid \(processIdentifier))"
}
if !children.isEmpty {
result += " { \(children.map(\.description).joined(separator: ", ")) }"
}
return result
}
fileprivate func containsLocalFrame(withOrigin origin: String) -> Bool {
self.origin == origin || children.contains { $0.containsLocalFrame(withOrigin: origin) }
}
}
/// The frame trees of a page in each of its web processes.
///
/// Their order doesn't matter when comparing them, so a test can write them in any order.
public struct FrameTreesInProcesses: Sendable, Equatable, ExpressibleByArrayLiteral, CustomStringConvertible {
/// The frame tree in each process.
public let trees: [FrameTree]
fileprivate init(_ trees: [FrameTree]) {
self.trees = trees
}
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
public init(arrayLiteral trees: FrameTree...) {
self.init(trees)
}
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
public static func == (lhs: FrameTreesInProcesses, rhs: FrameTreesInProcesses) -> Bool {
lhs.trees.hasSameElementsInAnyOrder(as: rhs.trees)
}
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
public var description: String {
"[\(trees.map(\.description).joined(separator: "; "))]"
}
/// Returns the identifier of the process that hosts frames with an origin.
///
/// - Parameter origin: An origin, such as `https://webkit.org`.
/// - Returns: The identifier of the process, or `nil` if no process has a local frame with the origin.
public func processIdentifier(hosting origin: String) -> pid_t? {
trees.first { $0.containsLocalFrame(withOrigin: origin) }?.processIdentifier
}
}
extension WKWebView {
/// Gets the frame tree of the page in each of its web processes.
///
/// - Returns: The frame trees, starting with the one from the process that hosts the main frame.
public func frameTreesInProcesses() async -> FrameTreesInProcesses {
let trees: Set<_WKFrameTreeNode>? = await _frameTrees()
// Put the main frame's process first, so that the trees read like the expected trees in most tests.
let mainFrameProcessFirst = (trees ?? []).map(FrameTree.init).sorted { $0.origin != nil && $1.origin == nil }
return FrameTreesInProcesses(mainFrameProcessFirst)
}
}
// MARK: Processes
/// Returns whether a process is running.
///
/// - Parameter processIdentifier: The identifier of the process, such as one from ``FrameTree/processIdentifier``.
/// - Returns: Whether the process is running.
public func isProcessRunning(_ processIdentifier: pid_t) -> Bool {
kill(processIdentifier, 0) == 0
}
// MARK: Opened windows
/// A UI delegate that opens a site-isolated web view for each window a page opens, so that a test can wait for them in
/// order.
///
/// The recorder keeps the windows that open before a test waits for them. A web view only holds its UI delegate weakly,
/// so keep the recorder alive for as long as the web view can open windows.
@MainActor
public final class OpenedWebViewRecorder: NSObject, WKUIDelegate {
private var openedWebViews: [(webView: TestWKWebView, navigationDelegate: TestNavigationDelegate)] = []
/// Waits for the next window to open, if one hasn't already.
///
/// - Parameter timeout: How long to wait for the window.
/// - Returns: The web view of the window and its navigation delegate, which trusts any server certificate.
/// - Throws: ``ConditionTimedOut`` if no window opens before the timeout.
public func nextOpenedWebView(
timeout: Duration = .seconds(10)
) async throws -> (webView: TestWKWebView, navigationDelegate: TestNavigationDelegate) {
try await waitForCondition("a window to open", timeout: timeout) {
!openedWebViews.isEmpty
}
return openedWebViews.removeFirst()
}
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
public func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
let openedWebView = siteIsolatedWebViewAndDelegate(configuration: configuration)
openedWebViews.append(openedWebView)
return openedWebView.webView
}
}
// MARK: Alerts
/// A UI delegate that records the message of each JavaScript alert, so that a test can wait for them in order.
///
/// The recorder keeps alerts that arrive before a test waits for them, so a test can start a load or script that shows
/// an alert, and then wait for it. A web view only holds its UI delegate weakly, so keep the recorder alive for as long
/// as the web view can show alerts.
///
/// It can also present the dialogs of a `WebPage`.
@MainActor
public final class AlertRecorder: NSObject, WKUIDelegate {
private var messages: [String] = []
/// Waits for the next alert, if it hasn't already been shown.
///
/// - Parameter timeout: How long to wait for the alert.
/// - Returns: The message of the alert.
/// - Throws: ``ConditionTimedOut`` if no alert is shown before the timeout.
public func nextAlert(timeout: Duration = .seconds(10)) async throws -> String {
try await waitForCondition("a JavaScript alert", timeout: timeout) {
!messages.isEmpty
}
return messages.removeFirst()
}
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
public func webView(
_ webView: WKWebView,
runJavaScriptAlertPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo
) async {
messages.append(message)
}
}
#if ENABLE_SWIFTUI
extension AlertRecorder: WebPage.DialogPresenting {
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
public func handleJavaScriptAlert(message: String, initiatedBy frame: WebPage.FrameInfo) async {
messages.append(message)
}
}
#endif // ENABLE_SWIFTUI