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 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.1 | Chrome (the norm) | Safari |
|---|---|---|
Declared in host_permissions | grants it | only asks |
| User must allow it per-site | no | yes — once, by hand |
| Before the grant | works | silently 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.
/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.mdThat 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.
: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.mdTwo 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.