Part 2 of 3 · The design meets the machine

Proving it in Safari

Every decision we could make without a browser, we tested without a browser. But the parts that only a real browser can run had to be proven in Safari itself — by driving Safari like a robot and reading what came out the other end. Here is how, and what Safari made us learn before it would cooperate.

In Part 1 we kept the decisions (what fact does this event become? how does the buffer stay consistent?) separate from the browser plumbing (how do I hear the event? how do I save a string?). That split has a profound payoff: the decisions are ordinary functions, so they can be tested in milliseconds, thousands of times, with no browser in sight. And they were.

But it also draws a hard, honest line. Some code cannot be tested that way, because it does nothing but call a browser-only power — chrome.storage.local, chrome.alarms, the navigation feed, the DOM. There is no way to make those pass-or-fail off a real browser without faking the browser — and a test of a fake is a test of nothing. The temptation is to write a pretend chrome.storage so the test goes green. It proves only that your pretend matches your expectations — never that Safari behaves the way you imagined. We refused to; the in-memory store we do use is a real, complete implementation, not a stand-in.

So the irreducible browser parts get a different kind of proof: we run them in Safari and check the result. The trick is doing that repeatably — not by hand-clicking and squinting, but with a rig that drives the real browser and reads the real output. Building that rig means meeting three ideas a newcomer hasn’t needed yet.

A robot, a marker, and a logbook

Click a piece of the rig to see what it is and why it’s there.

To check “when the extension sees a page visit, the right fact lands on disk,” we need to cause a page visit on demand, make it uniquely identifiable, and then look for exactly that one fact in the output. Three pieces, plus the two programs they sit between:

 

Put together, one test reads like a sentence: mint a nonce, tell the robot to open the fixture page carrying that nonce, wait, then ask the logbook whether a :page/visited fact with that nonce showed up. If it did, the whole chain — Safari’s navigation feed, our listener, the fact builder, the queue, the network flush, the receiver — works end to end. No fakes anywhere.

The gate nobody warns you about

Before any of that could run, Safari taught us its first lesson — one that has nothing to do with our code. Recall from Part 1 that the manifest requests host access to 127.0.0.1 so the worker can reach the receiver. In Chrome, declaring it is enough. In Safari, it is not.

Safari peculiarity #1 — declaring a permission is not getting it

Safari treats host_permissions as a request, not a grant. The extension’s first attempt to POST to 127.0.0.1 is silently blocked — it shows up as an “Ask” entry the user must find in Settings → Websites and set to Allow. Until they do, every fact the extension captures is gathered correctly and then flushed into a wall. No error in the page; nothing in the log.

This was discovered in an earlier throwaway probe The transport probe (“M0-S0”) that proved a Safari background worker can reach a localhost server at all — and uncovered the manual-grant requirement. m0-s0-transport-verdict.md and it changes the product, not just the test: a fresh install will appear to do nothing until the user grants that one host. So the extension has to tell them — which is why its toolbar popup is first-run guidance pointing at exactly that setting. A peculiarity you can’t guess from the spec, surfaced only by running the real thing.

Reaching 127.0.0.1Chrome (the norm)Safari
Declared in host_permissionsgrants itonly asks
User must allow it per-sitenoyes — once, by hand
Before the grantworkssilently blocked

The worker that keeps dozing off

Part 1 warned that the background worker is unloaded when idle. Safari enforces this briskly, and it is worth seeing why two design choices follow directly from it — because getting them wrong produces an extension that works in testing and quietly fails after a few minutes of real use.

First, the worker reloads its state on every wake: it can never assume the buffer it built a minute ago is still in memory, so the very first thing it does when it starts is read its facts back from storage. The wake routine: recover identity from storage, prune old facts, flush once. background.cljs:105 Second, the periodic flush is driven by a browser alarm, not a JavaScript timer. A timer (setInterval) lives in memory and dies when the worker is unloaded — so it would simply stop firing. An alarm is held by the browser, which re-wakes the worker to service it. “An in-memory timer can’t wake an unloaded worker.” background.cljs:108 creates the alarm; background.cljs:95 services it.

These are not Safari bugs — they are the MV3 model working as intended. But they are invisible to a quick test (which never idles long enough for the worker to be unloaded), which is exactly why they are easy to get wrong and important to design for up front.

The linchpin: is the extension even there?

Here is a subtlety that can void an entire test campaign. When safaridriver opens Safari, it opens a special automation window — a clean, isolated session. We loaded our extension into a normal window by hand. Does a hand-loaded extension even exist in the robot’s automation window? If not, every “the fact didn’t appear” result would be meaningless — the extension was never there to capture anything.

So the very first spike, before testing any specific behaviour, was a bare presence check we called the canary: drive the robot to a nonce-bearing page and see whether any fact at all comes out.

The canary lived.
A driver-navigation to /step/61b42662…/canary produced a real :page/visited fact in the log — non-zero tab id, a freshly minted device id, acknowledged by the receiver. The hand-loaded extension is present and firing inside the automation window; the whole pipeline runs end to end in real Safari. Every later verdict can be trusted. m0-s1-verdict.md

That single green line is the foundation the rest rests on: it says the automated rig actually exercises the real extension, not a ghost. With the linchpin in place, we could ask sharper questions.

The phantom tab id

Web extensions have a famous rough edge: when a brand-new tab is opening, the browser sometimes reports its navigation before the tab has a stable identity — delivering a placeholder tabId of 0. A fact stamped with tab 0 is poison: every new-tab visit would look like it happened in the same nonexistent tab. Our design has logic to hold such an event until the real id settles. Does Safari trip this wire?

We drove the robot to open a genuinely fresh tab (via the WebDriver “new window” command) and pointed it at a nonce page, then checked the tab id on the resulting fact.

No phantom.
The brand-new tab’s :page/visited carried a real, non-zero :tab/id — never 0. Across every navigation in the session, not one fact was stamped with tab 0; the “never emit zero” invariant held. m0-s3-verdict.md

Two questions asked, two clean passes. It would be a tidy story to end here. But a third behaviour — the one that needed the content script, the bridge, the two-worlds rule from Part 1 — produced something worse than a failure. It produced nothing at all, with no error to explain it.

Two passes, and a silence

We clicked a link in a driven page. By the design, that should have produced a :link/clicked fact: content script feels the click, messages the background, background builds the fact, the queue saves it. We looked in the log.

The log held the page visits. It did not hold the click. No error, no warning, no rejected request — the fact simply was not there. A silent gap is the hardest kind of bug, because nothing is asking for your attention. Finding out why — and what it reveals about the bridge between the two worlds — is Part 3.

Part 2 of 3 · The spike verdicts are real records under docs/explorations/; the rig is the instrument/ project. Build: Safari 26.5.

Citation colour marks the source — extension the extension’s own code, instrument/ the test rig, verdict a spike result.

instrument/safari.clj:10,25,28 instrument/fixtures.clj:7,9 instrument/oracle.clj:35 receiver/core.clj:159 m0-s1-verdict.md m0-s3-verdict.md background.cljs:95,108