Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Testing Safari Content Blocker JSON Rules

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.

Content-blocker JSON schema: what the fields actually mean

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:

FieldLocationNotes
url-filtertriggerRequired. Regular expression against the full resource URL. Escape literal dots as \\..
resource-typetriggerOptional 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-domaintriggerOptional domain scoping.
typeactionRequired. block is explicitly documented in the archive guide. Other action types are described in the Safari Content-Blocking Rules Reference.
selectoractionRequired 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.

Compiling and loading the rule list

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.

A local HTTP server and a WKWebView XCTest with JSON evidence

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.

Running this on hosted Mac hardware

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.

Request a configuration →


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms