
Short answer: compile your JSON rules with WKContentRuleListStore, load them into a WKWebView pointed at a local test server, and assert didFailProvisionalNavigation fires for blocked main-frame URLs. The $29 virtual Intel macOS plan on Figment Mac hosting can run this test on a compatible Intel Xcode (26 or earlier, per Apple's porting guide); pick the M-series tier from $95/month when you need an Apple-silicon-only toolchain. Exact chip, RAM and disk for M-series are set in the configuration quote.
Content-blocker JSON compiles whether or not the rules actually fire. A regex that matches nothing still produces a valid WKContentRuleList, and Safari will happily load the ads you meant to block. The only reliable check is to drive a real WKWebView over a real URL and observe the outcome. This guide builds that check into one XCTest with a local HTTP server and a JSON evidence attachment.
A rule list is a JSON array. Each element is an object with two top-level keys: trigger and action. The archived Apple Content Blocker Extension guide (developer.apple.com/library/archive/.../ContentBlocker.html) shows the shape:
[
{
"trigger": {
"url-filter": "tracker\\.example\\.com",
"resource-type": ["script", "image"]
},
"action": { "type": "block" }
},
{
"trigger": {
"url-filter": "cdn\\.ads\\.example\\.com/banner",
"resource-type": ["image"]
},
"action": { "type": "block" }
}
]
Fields you will touch most:
| Field | Location | Notes |
|---|---|---|
url-filter | trigger | Required. Regular expression against the full resource URL. Escape literal dots as \\.. |
resource-type | trigger | Optional array. The archive guide confirms script and image; the Safari Content-Blocking Rules Reference lists additional values. Verify against the current reference for your Safari version. |
if-domain / unless-domain | trigger | Optional domain scoping. |
type | action | Required. block is explicitly documented in the archive guide. Other action types are described in the Safari Content-Blocking Rules Reference. |
selector | action | Required when type is a CSS-hiding action. |
The archive guide does not state a rule count cap. Third-party developers often quote a practical ceiling, but do not treat an unspecified number as a published limit — measure against your own rule set. Both WKContentRuleListStore and WKUserContentController are @MainActor classes, so you touch them on the main thread.
WKContentRuleListStore exposes compileContentRuleList(forIdentifier:encodedContentRuleList:completionHandler:) and a matching lookUpContentRuleList(...). Compiled lists persist in the store, so repeat runs can reuse the identifier.
// ContentBlockerTestHelper.swift (test target)
import WebKit
import XCTest
@MainActor
enum ContentBlockerTestHelper {
static func compileRules(from jsonURL: URL, identifier: String) async throws -> WKContentRuleList {
let json = try String(contentsOf: jsonURL, encoding: .utf8)
guard let store = WKContentRuleListStore.default() else {
throw NSError(domain: "ContentBlocker", code: -2,
userInfo: [NSLocalizedDescriptionKey: "Default rule list store unavailable"])
}
return try await withCheckedThrowingContinuation { continuation in
store.compileContentRuleList(forIdentifier: identifier,
encodedContentRuleList: json) { list, error in
if let error = error {
continuation.resume(throwing: error)
} else if let list = list {
continuation.resume(returning: list)
} else {
continuation.resume(throwing: NSError(domain: "ContentBlocker", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Compile returned nil without error"]))
}
}
}
}
static func removeRules(identifier: String) async {
guard let store = WKContentRuleListStore.default() else { return }
await withCheckedContinuation { continuation in
store.removeContentRuleList(forIdentifier: identifier) { _ in
continuation.resume()
}
}
}
}
A non-nil error means the JSON was structurally rejected. Both arguments nil means the JSON parsed but produced no rules — usually a typo in trigger. The helper is illustrative; run it inside your own test target to confirm behaviour on your Xcode and SDK combination.
WKContentRuleList filters sub-resources (scripts, images, stylesheets) inside the network layer, so those blocks do not raise WKNavigationDelegate callbacks. For deterministic assertions, write rules whose url-filter matches the main document URL so the top-level navigation fails and didFailProvisionalNavigation fires. For sub-resource rules, assert against the loaded DOM with evaluateJavaScript.
Start a local HTTP server, and keep its files in a unique directory per run so parallel jobs do not collide:
# Setup step before running the XCTest.
TEST_SERVER_DIR="$(mktemp -d -t cb-rule-test)"
cp -R ./Tests/Fixtures/content-blocker/* "$TEST_SERVER_DIR/"
python3 -m http.server 8765 --directory "$TEST_SERVER_DIR" &
TEST_SERVER_PID=$!
trap 'kill "$TEST_SERVER_PID" 2>/dev/null; rm -rf "$TEST_SERVER_DIR"' EXIT
Two stub pages are enough: blocked.html (a block rule points at its URL) and safe.html (no rule points at it). Example rule file loaded from the test bundle:
[
{ "trigger": { "url-filter": "localhost:8765/blocked\\.html" },
"action": { "type": "block" } }
]
The XCTest records every navigation outcome, attaches a JSON summary, and removes the compiled rule list during teardown so the next run starts clean.
// ContentBlockerRuleTests.swift
import XCTest
import WebKit
struct RuleMatchRecord: Codable {
let url: String
let outcome: String // "blocked" | "allowed"
let errorDomain: String?
let errorCode: Int?
}
struct RuleMatchSummary: Codable {
let identifier: String
let records: [RuleMatchRecord]
}
@MainActor
final class RecordingNavigationDelegate: NSObject, WKNavigationDelegate {
var records: [RuleMatchRecord] = []
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
let url = webView.url?.absoluteString ?? "unknown"
records.append(RuleMatchRecord(url: url, outcome: "allowed",
errorDomain: nil, errorCode: nil))
}
func webView(_ webView: WKWebView,
didFailProvisionalNavigation navigation: WKNavigation!,
withError error: Error) {
let ns = error as NSError
let url = webView.url?.absoluteString
?? (ns.userInfo[NSURLErrorFailingURLStringErrorKey] as? String)
?? "unknown"
records.append(RuleMatchRecord(url: url, outcome: "blocked",
errorDomain: ns.domain, errorCode: ns.code))
}
}
@MainActor
final class ContentBlockerRuleTests: XCTestCase {
let identifier = "ci-test-blocker-\(UUID().uuidString)"
let serverBase = "http://localhost:8765"
override func tearDown() async throws {
await ContentBlockerTestHelper.removeRules(identifier: identifier)
}
func testRulesBlockExpectedURLs() async throws {
let rulesURL = try XCTUnwrap(
Bundle(for: type(of: self)).url(forResource: "rules", withExtension: "json"),
"rules.json not found in test bundle"
)
let ruleList = try await ContentBlockerTestHelper.compileRules(
from: rulesURL, identifier: identifier
)
let config = WKWebViewConfiguration()
config.userContentController.add(ruleList)
// Fresh WKWebView per URL so a second load() does not cancel the first
// navigation and get mis-reported as a block.
var allRecords: [RuleMatchRecord] = []
for page in ["blocked.html", "safe.html"] {
let delegate = RecordingNavigationDelegate()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = delegate
let url = URL(string: "\(serverBase)/\(page)")!
webView.load(URLRequest(url: url))
let deadline = Date().addingTimeInterval(5)
while delegate.records.isEmpty && Date() < deadline {
try await Task.sleep(nanoseconds: 100_000_000)
}
XCTAssertFalse(delegate.records.isEmpty,
"No navigation outcome for \(page). Is the test server running on 8765?")
allRecords.append(contentsOf: delegate.records)
}
let summary = RuleMatchSummary(identifier: identifier, records: allRecords)
attachSummary(summary)
let blocked = allRecords.filter { $0.outcome == "blocked" }.count
let allowed = allRecords.filter { $0.outcome == "allowed" }.count
XCTAssertGreaterThan(blocked, 0,
"Expected at least one blocked navigation; check rules.json patterns match the main-frame URL.")
XCTAssertGreaterThan(allowed, 0,
"Expected at least one allowed navigation; check safe.html loads.")
}
private func attachSummary(_ summary: RuleMatchSummary) {
guard let data = try? JSONEncoder().encode(summary) else { return }
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
attachment.name = "rule-match-summary.json"
attachment.lifetime = .keepAlways
add(attachment)
}
}
After the run, Xcode's Report Navigator shows rule-match-summary.json on the test. The payload shape looks like this; the exact errorDomain and errorCode depend on your WebKit version and are intentionally left unasserted so the test keeps working across SDKs:
{
"identifier": "ci-test-blocker-<uuid>",
"records": [
{ "url": "http://localhost:8765/blocked.html",
"outcome": "blocked", "errorDomain": "<webkit-domain>", "errorCode": 0 },
{ "url": "http://localhost:8765/safe.html",
"outcome": "allowed", "errorDomain": null, "errorCode": null }
]
}
A zero blocked count when you expected blocking almost always means the url-filter regex did not match the main-frame URL, or the rule targets a sub-resource. For the sub-resource case, swap the navigation assertion for evaluateJavaScript("document.querySelectorAll('script[src*=\\\"tracker\\\"]').length") after the page loads and expect 0. A WKWebView created off-screen still fires navigation delegates; layout-sensitive JavaScript (sizes, media queries) may not, so keep the assertions focused on network behaviour.
WKContentRuleListStore ships only inside Apple's WebKit framework on macOS and iOS; a Linux CI runner cannot execute this test, and WebKit automation stacks such as Playwright-WebKit bundle a separate build of WebKit and do not expose the content-rule-list API. You need a macOS host.
The $29 virtual Intel macOS plan on Figment runs a full macOS desktop in the browser console with 2 vCPU, 8 GiB RAM and 80 GiB disk. There is no GPU acceleration or audio. Access is through the browser-console desktop; inbound SSH is not currently implemented, so outbound CI agents and remote Git pushes are the usual ways to drive it from another machine. A compatible Intel Xcode (26 or earlier) will compile this XCTest, run the WebKit framework, and produce the JSON attachment. For Xcode 27, visionOS targets, or ARM-only execution you need the M-series tier from $95/month; the specific chip, RAM and disk are stated in the configuration quote rather than listed on the plan page. Physical Intel ($49) and M-series requests are collected until supply is confirmed. No free trial, SLA or discount is published for these plans.
Worked cost check for a single blocker project running this test on demand: 1 × $29 virtual Intel = $29/month; 12 × $29 = $348/year at today's monthly rate. A two-week project still needs the full monthly payment — there is no daily or pro-rated term. Confirm the current price and availability in the configuration quote before you commit.