TL;DR
I used ios-webkit-debug-proxy and safaridriver to debug issues on my web projects. This is a short write-up on how I did it, since Safari contains some unique bugs and is a real pain to debug and validate that the issue was actually fixed.
Debugging issues on iPad
The problem
While using one of my apps on iPad, I noticed that only some icons in the toolbar weren't rendering correctly - they were too small. The funny thing is that this didn't happen on any other machine or browser - I checked Chrome, Firefox, as well as Safari on an iPhone and Mac and the rendering was fine everywhere else - the issue was just on the iPad.

How to debug
The issue could stem from many different sources - maybe the issue is with how the CSS is loaded, how the icons get loaded (notice that some load correctly), it could be an issue with the iOS version running on the iPad, etc.
The problem is that screenshots can only get you so far - you can provide a screenshot to a coding agent and they can attempt to fix it, but they have no way to verify that the issue is actually fixed. Even if you manage to expose the program on a local network, you have to clear the page's cache every time (which has to be done through settings and takes ~1 min) and if the attempt failed, you have to do it all over again.
A better way of fixing this is to enable the agent to have direct access to the browser - for this, I used ios-webkit-debug-proxy, which exposes the iPad's Web Inspector protocol over a local WebSocket. To enable it, do the following: 1. Install via brew:
brew install ios-webkit-debug-proxy
2. Enable Safari's Web Inspector on the iPad:
- Go to Settings > Safari > Advanced > Web Inspector - toggle the setting to enable it
3. Connect your computer and the iPad via a cable and tap Trust
4. Get your device's UDID (by inspecting the device via Finder)
5. Start the server:
ios_webkit_debug_proxy -c <device-udid>:9222
curl http://localhost:9222/json # lists inspectable pages + ws:// URLs
How it works
To see all tabs that are open on the iPad, use curl http://localhost:9222/json which lists all open tabs, as well as ws:// URL for each one. Point a WebSocket at that URL and you are talking to the same Web Inspector that the Develop menu uses - only now a script is on the other end.
This is a simple example script that returns the title of the page that's open on the iPad:
import WebSocket from 'ws'
const [{ webSocketDebuggerUrl }] = await fetch('http://localhost:9222/json').then(r => r.json())
const ws = new WebSocket(webSocketDebuggerUrl)
ws.on('open', () => {
ws.send(JSON.stringify({ id: 1, method: 'Target.setPauseOnStart', params: { pauseOnStart: false } }))
})
ws.on('message', raw => {
const msg = JSON.parse(raw)
if (msg.method === 'Target.targetCreated') {
evaluate(msg.params.targetInfo.targetId, 'document.title') // helper below
}
})
The one catch on modern iOS is that you can't send Runtime.evaluate directly anymore; every command has to be tunneled through the Target domain, addressed to a target id that arrives in a Target.targetCreated event:
const evaluate = (targetId, expression) => ws.send(JSON.stringify({
id: 2,
method: 'Target.sendMessageToTarget',
params: {
targetId,
message: JSON.stringify({
id: 3,
method: 'Runtime.evaluate',
params: { expression, returnByValue: true },
}),
},
}))
With this helper, any JS expression runs in the live page on the device, and the result comes back to the terminal.
Fixing our issue
A single getComputedStyle query through that channel, comparing a broken icon against a working one, found the root of the issue straight away:
broken: { csW: "6px", csH: "18px", btnPadding: "17px/17px" }
working: { csW: "18px", csH: "18px", btnPadding: "0px/0px" }
The broken icons were sitting in buttons with 17px of horizontal padding that I never put there. iOS WebKit's default stylesheet gives every <button> a padding: 0 1em - and 1em is the button font size (17px). The buttons are 40px wide with box-sizing: border-box, so 34px of padding left a 6px box for an 18px icon. Anchors get no such padding, which is why the <a> back button was fine - and why only some of the icons looked wrong.
To confirm that clearing the padding was the actual fix, I used the same eval channel to run the experiment live - inject a <style> tag, force a layout re-render and re-measure:
const s = document.createElement('style')
s.textContent = 'button { padding: 0 }'
document.head.appendChild(s)
document.body.offsetHeight // force layout
// re-measure -> readSwitch: "18x18", bookmark: "18x18" ✅
For the final proof I retrieved the confirmation directly from the device - the protocol's Page.snapshotRect returns a PNG of any viewport rect, rendered by the device itself:

Debugging similar issues on Mac
A similar tool exists to debug issues on Safari on the same machine - safaridriver. It ships with macOS and speaks plain WebDriver over HTTP.
The problem
While the previous issue was virtually impossible to "debug by hand", this one is pretty easy - just open the browser, right? But in my use case, I was dealing with a bug regarding async execution in IndexedDB, and so the issue was really hard to replicate manually and it made much more sense to solve the issue using an AI agent instead of trying to solve everything myself.
When dealing with an issue like this, I usually use Playwright to let the agent figure it out. Playwright has the ability to switch between different browsers to run tests on them, but the interesting thing is that the issue wasn't reproducible in it. This is because Playwright's WebKit is not the same as the actual Safari browser - and so, we needed to run the fix on the actual browser.
How to debug
To start the safaridriver, do the following:
1. Open Safari > Settings > Advanced > "Show features for web developers"
2. Click Develop and "Allow remote automation" (once)
3. Then run the server:
safaridriver -p 4455
4. Get the session ID, which you will use to make all additional requests:
curl -s -X POST http://localhost:4455/session \
-H 'Content-Type: application/json' \
-d '{"capabilities":{"alwaysMatch":{"browserName":"safari"}}}'
After that every endpoint is a curl away - POST /session/<id>/url to navigate, POST /session/<id>/execute/sync to run an expression and get its value back.