diff --git a/CLAUDE.md b/CLAUDE.md index 38b4fb1..ad6c6f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,18 @@ tests don't catch anyway. host app without firing up the menubar / single-instance / event-tap machinery. +### End-to-end (live) — the Arc typing harness + +The CGEventTap path *is* testable end-to-end, just not as a unit test. Launch a +Debug build with `MOJITO_E2E_LOG=1` and `DebugRecorder` mirrors its activity log +into the unified log (subsystem `ee.wells.Mojito`, category `e2e`); a harness +then drives a real app and asserts on it. `scripts/e2e/arc-typing-regression.sh` +does exactly that for the Arc keystroke-drop class of bug (W-555): it types into +fresh Arc tabs and asserts zero `keyMonitor tapLost reason=timeout`. Needs Arc +installed + `Mojito Dev.app` granted Accessibility/Input Monitoring; see +`scripts/e2e/README.md`. The env flag is a no-op without the var, so production +never logs. Reusable for other Arc regressions via the same `e2e` log stream. + ### Things that have bitten us repeatedly - **You MUST run `xcodegen generate` after**: adding/removing/renaming a `.swift` file, changing `project.yml`, or touching anything under `Resources/` that's referenced by the build phase. `xcodebuild` will appear to succeed against a stale project without picking up new files. diff --git a/Sources/Mojito/Context/BrowserURL.swift b/Sources/Mojito/Context/BrowserURL.swift index d7865da..9a601d2 100644 --- a/Sources/Mojito/Context/BrowserURL.swift +++ b/Sources/Mojito/Context/BrowserURL.swift @@ -9,6 +9,26 @@ enum BrowserURL { guard let bundleID, let pid else { return nil } guard isBrowser(bundleID: bundleID) else { return nil } + // Arc suppresses the Chromium web-content a11y tree entirely — no + // AXWebArea, no AXURL, and its address bar is a transient command bar + // with no persistent AXTextField. The AX walk below always comes up nil + // for it, so per-site exclusions can only be read via AppleScript. Both + // of those cost matter here because `detect` runs inside the CGEventTap + // callback (see `Engine.process`): the synchronous AppleScript Apple + // Event AND the multi-node AX walk are each cross-process IPC that, on a + // busy Arc, blow past the tap timeout — macOS then disables the tap and + // drops the keystroke, which stalled Arc's command bar on every + // word+terminator (W-555). So for Arc we skip the AX walk entirely and + // serve the URL from an async cache (`BrowserURLCache`), which keeps the + // AppleScript on the main thread — where NSAppleScript is supported — + // but on its own run-loop turn, never inside the tap callback. Gated to + // this one bundle ID: it's the only browser that needs AppleScript (Dia, + // same vendor, exposes AXURL fine), and the first call triggers a + // one-time Automation prompt we don't want to inflict elsewhere. + if BrowserURLCache.appleScriptBundleIDs.contains(bundleID) { + return BrowserURLCache.shared.url(forBundleID: bundleID, pid: pid) + } + let app = AXUIElementCreateApplication(pid) // Most browsers expose the page URL as an `AXURL` attribute somewhere @@ -21,39 +41,9 @@ enum BrowserURL { if let raw = focusedAddressBarValue(in: app), let url = normalizedURL(from: raw) { return url } - - // Arc suppresses the Chromium web-content a11y tree entirely — no - // AXWebArea, no AXURL, and its address bar is a transient command bar - // with no persistent AXTextField. The AX paths above always return nil - // there, so per-site exclusions silently can't fire. AppleScript is the - // only way to read Arc's tab URL. It's gated to this one bundle ID - // because it's the sole browser we've found that needs it (Dia, same - // vendor, exposes AXURL fine) and because the first call triggers a - // one-time Automation permission prompt we don't want to inflict on - // browsers the AX path already handles. - if appleScriptURLBundleIDs.contains(bundleID), - let url = appleScriptURL(bundleID: bundleID) { - return url - } return nil } - private static let appleScriptURLBundleIDs: Set = [ - "company.thebrowser.Browser", // Arc - ] - - /// `URL of active tab of front window` via AppleScript. Returns nil on any - /// failure — no window, denied Automation permission, or a non-URL value — - /// so callers fall through to "no URL" exactly as if AX had come up empty. - private static func appleScriptURL(bundleID: String) -> URL? { - let source = "tell application id \"\(bundleID)\" to return URL of active tab of front window" - guard let script = NSAppleScript(source: source) else { return nil } - var error: NSDictionary? - let result = script.executeAndReturnError(&error) - guard error == nil, let raw = result.stringValue else { return nil } - return normalizedURL(from: raw) - } - private static func isBrowser(bundleID: String) -> Bool { knownBrowserBundleIDs.contains(bundleID) } @@ -179,10 +169,146 @@ enum BrowserURL { return result == .success ? ref : nil } - private static func normalizedURL(from raw: String) -> URL? { + fileprivate static func normalizedURL(from raw: String) -> URL? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } if trimmed.contains("://") { return URL(string: trimmed) } return URL(string: "https://" + trimmed) } } + +/// URL cache for browsers whose tab URL is only readable via AppleScript (Arc). +/// `BrowserURL.detect` is called synchronously inside the CGEventTap callback, +/// so it can't run the AppleScript there — a slow Apple Event trips the tap +/// timeout and drops the keystroke (W-555). But `NSAppleScript` is documented +/// main-thread-only (Cocoa Thread Safety Summary), so it can't just be shoved +/// onto a background queue either. +/// +/// Resolution: the hot-path read (`url(forBundleID:pid:)`) never does IPC — it +/// returns the last resolved value and schedules a refresh. The refresh runs +/// the AppleScript on the main thread, where it's supported, but via +/// `DispatchQueue.main.async` so it lands on its own run-loop turn rather than +/// inside the tap callback. A throttle keeps refreshes to at most one per +/// second, so the brief main-thread block can't recur per keystroke the way the +/// old synchronous call did. Same spirit as `FocusedElementCache` moving slow +/// IPC out of the tap path (W-547). Bounded staleness (≈ one keystroke, or one +/// navigation until the next read/refresh) is acceptable for per-site exclusion +/// matching; the value is only served for the pid it was resolved from, so it +/// can't bleed across an app switch. +@MainActor +final class BrowserURLCache { + static let shared = BrowserURLCache( + observeActivations: true, + minRefreshInterval: 1.0, + now: { Date() }, + resolver: { BrowserURLCache.appleScriptURL(bundleID: $0) } + ) + + static let appleScriptBundleIDs: Set = [ + "company.thebrowser.Browser", // Arc + ] + + /// Last resolved URL and the pid it belongs to. A `nil` url is a real + /// answer (no window / denied Automation / non-URL value), cached as such + /// via `haveResult` so we don't refetch it every keystroke. + private var cachedURL: URL? + private var cachedPID: pid_t? + private var haveResult = false + + /// Single-flight: one refresh scheduled at a time, so a burst of reads (a + /// word+terminator produces several) collapses to one AppleScript. Always + /// cleared by the scheduled block, so a failed/timed-out script can't wedge + /// it (the block runs regardless of the script's result). + private var refreshing = false + + /// Throttles opportunistic per-read refreshes; app-activation refreshes + /// bypass it (`force`) since a switch is infrequent and changes the pid. + private var lastRefreshAt: Date? + private let minRefreshInterval: TimeInterval + + /// Seams. Production wires the real `NSAppleScript` resolver and wall clock; + /// tests inject a stub resolver + controllable clock so the deferral, + /// single-flight, throttle, and pid-guard are checkable without AppleScript + /// or a real app switch (`observeActivations: false`). + private let now: () -> Date + private let resolver: (String) -> URL? + + init( + observeActivations: Bool, + minRefreshInterval: TimeInterval, + now: @escaping () -> Date, + resolver: @escaping (String) -> URL? + ) { + self.minRefreshInterval = minRefreshInterval + self.now = now + self.resolver = resolver + guard observeActivations else { return } + // Prefetch on activation so switching to Arc and immediately typing has + // a warm URL rather than a first-keystroke miss. The closure refers to + // `.shared` rather than capturing `self`, so it doesn't retain the + // singleton (which lives for the app's lifetime anyway). + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { _ in + MainActor.assumeIsolated { + guard let app = NSWorkspace.shared.frontmostApplication, + let bundleID = app.bundleIdentifier, + BrowserURLCache.appleScriptBundleIDs.contains(bundleID) else { return } + BrowserURLCache.shared.scheduleRefresh( + bundleID: bundleID, pid: app.processIdentifier, force: true + ) + } + } + } + + /// Cheap synchronous read for the hot path — no AppleScript, no AX IPC on + /// this thread, so it never blocks the tap callback. Returns the cached URL + /// only if it belongs to this pid, and schedules a throttled refresh so the + /// next read reflects any navigation. A pid mismatch (or no result yet) + /// reads as nil — exclusions treat that as "no URL", same as the AX paths + /// coming up empty; the refresh fills it in for the following keystroke. + func url(forBundleID bundleID: String, pid: pid_t) -> URL? { + let value = (haveResult && cachedPID == pid) ? cachedURL : nil + scheduleRefresh(bundleID: bundleID, pid: pid, force: false) + return value + } + + /// Resolves the URL on a *later* main-run-loop turn. `NSAppleScript` must + /// run on the main thread, but never inside the tap callback that calls + /// `detect` — `DispatchQueue.main.async` gives it its own turn, and the + /// throttle keeps it rare enough that the brief main-thread block can't + /// stall typing the way the per-keystroke synchronous call did. + private func scheduleRefresh(bundleID: String, pid: pid_t, force: Bool) { + guard !refreshing else { return } + if !force, haveResult, let last = lastRefreshAt, + now().timeIntervalSince(last) < minRefreshInterval { + return + } + refreshing = true + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.cachedURL = self.resolver(bundleID) + self.cachedPID = pid + self.haveResult = true + self.lastRefreshAt = self.now() + self.refreshing = false + } + } + } + + /// `URL of active tab of front window` via AppleScript. Returns nil on any + /// failure — no window, denied Automation permission, or a non-URL value — + /// so callers fall through to "no URL" exactly as if AX had come up empty. + /// Called only from `scheduleRefresh`'s main-thread block. + private static func appleScriptURL(bundleID: String) -> URL? { + let source = "tell application id \"\(bundleID)\" to return URL of active tab of front window" + guard let script = NSAppleScript(source: source) else { return nil } + var error: NSDictionary? + let result = script.executeAndReturnError(&error) + guard error == nil, let raw = result.stringValue else { return nil } + return BrowserURL.normalizedURL(from: raw) + } +} diff --git a/Sources/Mojito/Debug/DebugRecorder.swift b/Sources/Mojito/Debug/DebugRecorder.swift index 2453b5a..e25bbea 100644 --- a/Sources/Mojito/Debug/DebugRecorder.swift +++ b/Sources/Mojito/Debug/DebugRecorder.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import os /// Categories that surface in the debug report. Keep the set small — /// bug categories tend to cluster, and a tight enum makes the report @@ -42,6 +43,26 @@ enum DebugRecorder { private static var buffer: [DebugEvent] = [] private static var focusBuffer: [DebugEvent] = [] + /// E2E introspection. The in-app report is UI-only, so when the app is + /// launched with `MOJITO_E2E_LOG=1` every recorded event is also emitted to + /// the unified log (subsystem `ee.wells.Mojito`, category `e2e`) where an + /// external harness can read the activity stream headlessly — e.g. asserting + /// zero `keyMonitor tapLost reason=timeout` while driving a browser + /// (`scripts/e2e/`). Off by default: production never constructs the logger, + /// so there's no cost or behavior change. + /// + /// The message is logged `%{public}` (required to read it back with `log + /// show`). That is only safe because every current `record` call site passes + /// non-sensitive metadata — enum-y kinds and bundle IDs. The `clamp` below + /// caps length and strips non-ASCII, which bounds accidental spillage but is + /// NOT anonymization: a future call site that put user text in metadata would + /// disclose (a truncated prefix of) it here. Keep metadata non-sensitive, or + /// redact per-field, rather than relying on the clamp. + private static let e2eLog: Logger? = + ProcessInfo.processInfo.environment["MOJITO_E2E_LOG"] == "1" + ? Logger(subsystem: "ee.wells.Mojito", category: "e2e") + : nil + static func record( _ category: DebugCategory, _ kind: String, @@ -73,6 +94,15 @@ enum DebugRecorder { buffer.removeFirst(buffer.count - capacity) } } + + if let e2eLog { + let meta = event.metadata + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + // Machine-readable shape for the harness: "category kind k=v k=v". + e2eLog.log("\(event.category.rawValue, privacy: .public) \(event.kind, privacy: .public) \(meta, privacy: .public)") + } } /// Action events (everything but focus changes). Newest-last; callers diff --git a/Tests/MojitoTests/BrowserURLCacheTests.swift b/Tests/MojitoTests/BrowserURLCacheTests.swift new file mode 100644 index 0000000..3fbea71 --- /dev/null +++ b/Tests/MojitoTests/BrowserURLCacheTests.swift @@ -0,0 +1,162 @@ +import Foundation +import Testing +@testable import Mojito + +/// Guards the W-555 fix: reading Arc's tab URL must never do blocking IPC on the +/// caller's thread (that thread is the CGEventTap callback, and a synchronous +/// AppleScript there tripped the tap timeout and dropped keystrokes). These +/// pin the concurrency contract — deferred resolve, single-flight, throttle, +/// pid-guard — with a stub resolver and a controllable clock, so no live Arc, +/// AppleScript, or app switch is needed. +/// +/// The end-to-end "keys don't drop while typing in Arc" behavior is timing- and +/// Arc-dependent and can't be asserted deterministically; this instead nails the +/// property that *causes* the drop when violated. +@MainActor +struct BrowserURLCacheTests { + + private static let arc = "company.thebrowser.Browser" + + /// Records how the injected resolver was called — count, argument, and the + /// thread — so tests can assert the AppleScript work is deferred to a later + /// main-thread turn rather than run inline on the hot path. + private final class ResolverSpy { + var calls = 0 + var lastBundleID: String? + var ranOnMainThread = false + var stub: URL? + func resolve(_ bundleID: String) -> URL? { + calls += 1 + lastBundleID = bundleID + ranOnMainThread = Thread.isMainThread + return stub + } + } + + private final class Clock { + var now: Date + init(_ start: Date) { now = start } + } + + private func makeCache( + spy: ResolverSpy, + clock: Clock, + minRefreshInterval: TimeInterval = 1_000 + ) -> BrowserURLCache { + BrowserURLCache( + observeActivations: false, + minRefreshInterval: minRefreshInterval, + now: { clock.now }, + resolver: { spy.resolve($0) } + ) + } + + /// Let the main queue drain the `DispatchQueue.main.async` refresh block. + private func drain() async throws { + try await Task.sleep(nanoseconds: 30_000_000) + } + + // MARK: - The core contract + + @Test func hotPathReturnsNilWhenColdAndDefersTheResolve() async throws { + let spy = ResolverSpy(); spy.stub = URL(string: "https://example.com") + let cache = makeCache(spy: spy, clock: Clock(Date())) + + let value = cache.url(forBundleID: Self.arc, pid: 42) + + // Cold cache → nil, and — the crux — the resolver has NOT run yet. + // If it ran synchronously here, that's the AppleScript back on the tap + // thread and the bug is reintroduced. + #expect(value == nil) + #expect(spy.calls == 0) + + try await drain() + + // It runs on a later turn, on the main thread (where NSAppleScript is + // supported), exactly once. + #expect(spy.calls == 1) + #expect(spy.ranOnMainThread) + #expect(spy.lastBundleID == Self.arc) + } + + @Test func servesCachedValueForSamePidAfterRefresh() async throws { + let spy = ResolverSpy(); spy.stub = URL(string: "https://example.com") + let cache = makeCache(spy: spy, clock: Clock(Date())) + + _ = cache.url(forBundleID: Self.arc, pid: 42) + try await drain() + + #expect(cache.url(forBundleID: Self.arc, pid: 42) == URL(string: "https://example.com")) + } + + @Test func doesNotServeValueAcrossPidChange() async throws { + let spy = ResolverSpy(); spy.stub = URL(string: "https://example.com") + let cache = makeCache(spy: spy, clock: Clock(Date())) + + _ = cache.url(forBundleID: Self.arc, pid: 42) + try await drain() + + // A different pid means a different app instance — the URL resolved for + // pid 42 must not leak to pid 99. + #expect(cache.url(forBundleID: Self.arc, pid: 99) == nil) + } + + @Test func burstOfReadsCollapsesToOneResolve() async throws { + let spy = ResolverSpy(); spy.stub = URL(string: "https://example.com") + let cache = makeCache(spy: spy, clock: Clock(Date())) + + // A word+terminator produces several reads back-to-back; single-flight + // must collapse them to one AppleScript. + for _ in 0..<5 { _ = cache.url(forBundleID: Self.arc, pid: 42) } + try await drain() + + #expect(spy.calls == 1) + } + + @Test func throttleSkipsRefreshWithinIntervalThenAllowsAfter() async throws { + let spy = ResolverSpy(); spy.stub = URL(string: "https://a.example") + let clock = Clock(Date(timeIntervalSince1970: 10_000)) + let cache = makeCache(spy: spy, clock: clock, minRefreshInterval: 1.0) + + _ = cache.url(forBundleID: Self.arc, pid: 42) + try await drain() + #expect(spy.calls == 1) + + // Within the throttle window: read again, no new resolve, still serving A. + clock.now += 0.5 + spy.stub = URL(string: "https://b.example") + #expect(cache.url(forBundleID: Self.arc, pid: 42) == URL(string: "https://a.example")) + try await drain() + #expect(spy.calls == 1) + + // Past the window: the next read refreshes and picks up B. + clock.now += 0.6 + _ = cache.url(forBundleID: Self.arc, pid: 42) + try await drain() + #expect(spy.calls == 2) + #expect(cache.url(forBundleID: Self.arc, pid: 42) == URL(string: "https://b.example")) + } + + @Test func hotPathReturnsPromptlyEvenIfResolverWouldBlock() async throws { + // Simulate a slow AppleScript: the resolver sleeps. The hot-path read + // must still return without waiting for it (the whole point of the fix). + let cache = BrowserURLCache( + observeActivations: false, + minRefreshInterval: 0, + now: { Date() }, + resolver: { _ in Thread.sleep(forTimeInterval: 0.2); return URL(string: "https://slow.example") } + ) + + let start = Date() + _ = cache.url(forBundleID: Self.arc, pid: 42) + let elapsed = Date().timeIntervalSince(start) + + // The read itself did no blocking work — well under the resolver's 200ms. + #expect(elapsed < 0.05) + try await drain() + } + + @Test func arcIsGatedIntoTheAppleScriptPath() { + #expect(BrowserURLCache.appleScriptBundleIDs.contains(Self.arc)) + } +} diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md new file mode 100644 index 0000000..2032f33 --- /dev/null +++ b/scripts/e2e/README.md @@ -0,0 +1,81 @@ +# E2E harness — live Arc typing regression + +Unit tests can't touch the `CGEventTap`. This harness drives a real Mojito build +against a real app and asserts the tap stays healthy — built for the class of +bugs Arc keeps producing, where slow work on Mojito's event-tap path makes macOS +disable the tap by timeout and **drop keystrokes** (W-555). + +## What's here + +- `arc-typing-regression.sh` — the harness. Relaunches the Mojito Dev build with + E2E logging on, types terminator-heavy phrases into fresh Arc tabs, and asserts + **zero `keyMonitor tapLost reason=timeout`** events. +- `cgtype.swift` — synthetic keystroke helper (compiled on the fly by the harness). + +## The signal + +With `MOJITO_E2E_LOG=1`, `DebugRecorder` mirrors its activity log into the unified +log (subsystem `ee.wells.Mojito`, category `e2e`) — see +`Sources/Mojito/Debug/DebugRecorder.swift`. The harness reads that log over the +run's time window (`log show`, not a streamed pipe — no attach race) and counts +tap timeouts. That event *is* the dropped-keystroke bug; the flag is a no-op (the +logger isn't even constructed) without the env var, so production is untouched. + +## Guardrails against a false green + +A test harness that passes while testing nothing is worse than none, so: + +- **Builds from this worktree by default** (`--no-build` / `--app` to override). + A regressed-but-unrebuilt binary can't sneak a stale green through. +- **Probes first.** Before the stress cycles it types a `:` trigger and requires + an `engine colon` event to appear — positive proof that synthetic HID + keystrokes actually reach Mojito's tap. No probe hit → **inconclusive** (exit 2), + never a pass. (Merely activating Arc emits a focus event, so "saw Arc" is not + proof anything was typed.) +- **Fails hard on driver errors** — `cgtype` exits non-zero if it can't post an + event, and every drive step is checked; a bad `--cycles` is rejected up front. + +## Requirements (the harness checks, but can't grant) + +- **Arc installed.** +- **The dev toolchain + a working Debug build** — `xcodebuild`/`swiftc`, and the + gitignored bits (Giphy key, per CLAUDE.md) in place so the build succeeds. +- **Accessibility + Input Monitoring granted to `Mojito Dev.app`.** Grants persist + across rebuilds because the "Mojito Dev" signing identity is stable. +- **Accessibility for the driving terminal** — `cgtype` posts HID CGEvents, which + needs the invoking process permitted. + +## Run + +```bash +scripts/e2e/arc-typing-regression.sh # build, 5 cycles, default phrase +scripts/e2e/arc-typing-regression.sh --cycles 10 +scripts/e2e/arc-typing-regression.sh --no-build # reuse the last build +scripts/e2e/arc-typing-regression.sh --app "/Applications/Mojito Dev.app" +``` + +Exit: `0` healthy, `1` timeouts observed (bug), `2` inconclusive/precondition +(Arc missing, build failed, or the probe never reached the tap — all reported, +never a false pass). + +## Gotchas baked in (learned the hard way) + +- **HID CGEvents, not AppleScript `keystroke`.** System Events keystrokes do NOT + traverse Mojito's `.cgSessionEventTap` — they never reach the engine, so they + can't reproduce (or stress) a tap-path bug. `cgtype` posts at `.cghidEventTap`. + Verified: HID-posted `:tada` fires `engine colon` / `picker open`; System + Events `:tada` fires nothing. +- **`/usr/bin/log`, not `log`.** `log` is a zsh builtin that shadows the binary + and silently prints nothing. +- **Launch via `launchctl setenv` + `open`, not direct-exec.** A directly-exec'd + bundle binary doesn't come up as a proper GUI app, so there are no NSWorkspace + notifications to observe. LaunchServices launch inherits the launchd env, and + TCC grants key off the code signature, so they still apply. + +## Extending + +Add scenarios by parameterizing the target/phrase, or add new `cgtype` verbs +(it does `type`, `hotkey `, `key `). The same os_log +stream exposes `engine`, `picker`, `insert`, `focus`, and `permissions` events — +so other Arc regressions (picker mispositioning, exclusions) can assert on those +too. diff --git a/scripts/e2e/arc-typing-regression.sh b/scripts/e2e/arc-typing-regression.sh new file mode 100755 index 0000000..a39bb4e --- /dev/null +++ b/scripts/e2e/arc-typing-regression.sh @@ -0,0 +1,201 @@ +#!/bin/bash +# +# End-to-end regression harness for the Arc typing-stall class of bug (W-555). +# +# Arc suppresses its web accessibility tree, so Mojito reaches for AppleScript / +# AX paths that don't behave like other browsers — and slow work on those paths, +# run inside the CGEventTap callback, makes macOS disable the tap by timeout and +# DROP the keystroke. Unit tests can't exercise the live tap, so this drives real +# Arc + a real Mojito build and asserts the symptom is absent. +# +# How it works: +# 1. Build Mojito Dev from THIS worktree (so a regressed-but-unrebuilt binary +# can't pass green), and relaunch it with MOJITO_E2E_LOG=1 — which mirrors +# the in-app DebugRecorder activity log into the unified log (subsystem +# ee.wells.Mojito, category e2e). See Sources/Mojito/Debug/DebugRecorder.swift. +# 2. PROBE: type a `:` trigger and require an `engine colon` event to appear. +# That proves synthetic HID keystrokes actually reach Mojito's tap; without +# it the run is inconclusive (activating Arc alone emits a focus event, so +# "saw Arc" is NOT proof anything was typed). +# 3. Type terminator-heavy phrases into fresh Arc tabs (Cmd+T), repeated to +# provoke the busy-new-tab stall. +# 4. Read the e2e log over the recorded time window and assert ZERO +# `keyMonitor tapLost reason=timeout` events. That event IS the bug. +# +# Requirements (checked, but can't be granted by the script): +# - Arc installed. +# - The dev toolchain (xcodebuild/swiftc) + a working Debug build (the +# gitignored Giphy key etc. in place, per CLAUDE.md). +# - Accessibility + Input Monitoring granted to "Mojito Dev". +# - Accessibility for the driving terminal (to post HID CGEvents). +# +# Usage: +# scripts/e2e/arc-typing-regression.sh [--cycles N] [--phrase "text"] +# [--no-build] [--app PATH] [--keep] +# +# Exit codes: 0 pass, 1 fail (timeouts observed), 2 inconclusive/precondition. + +set -uo pipefail + +# ---- config / args --------------------------------------------------------- +CYCLES=5 +PHRASE="how to cook a great meal for dinner today" +PROBE=":tada " # colon trigger → must produce `engine colon` +KEEP_RUNNING=0 +DO_BUILD=1 +DEV_APP="" # resolved from the build unless --app given +ARC_BUNDLE="company.thebrowser.Browser" +SUBSYSTEM="ee.wells.Mojito" +SETTLE_SECS=3 +LOG=/usr/bin/log # `log` is a zsh builtin that shadows this +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PRED="subsystem == \"$SUBSYSTEM\" && category == \"e2e\"" + +while [ $# -gt 0 ]; do + case "$1" in + --cycles) CYCLES="$2"; shift 2 ;; + --phrase) PHRASE="$2"; shift 2 ;; + --app) DEV_APP="$2"; DO_BUILD=0; shift 2 ;; + --no-build) DO_BUILD=0; shift ;; + --keep) KEEP_RUNNING=1; shift ;; + -h|--help) sed -n '2,40p' "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +case "$CYCLES" in ''|*[!0-9]*) echo "--cycles must be a positive integer" >&2; exit 2 ;; esac +[ "$CYCLES" -ge 1 ] || { echo "--cycles must be >= 1" >&2; exit 2; } + +CGTYPE="" +PRIOR_ENV=""; HAD_PRIOR_ENV=0 + +say() { printf '\033[1m> %s\033[0m\n' "$*"; } +fail() { printf '\033[31mFAIL: %s\033[0m\n' "$*" >&2; } +ok() { printf '\033[32mOK: %s\033[0m\n' "$*"; } + +cleanup() { + [ -n "$CGTYPE" ] && rm -f "$CGTYPE" + # Restore the session env var to exactly what it was (or clear it). + if [ "$HAD_PRIOR_ENV" -eq 1 ]; then + launchctl setenv MOJITO_E2E_LOG "$PRIOR_ENV" 2>/dev/null + else + launchctl unsetenv MOJITO_E2E_LOG 2>/dev/null + fi + if [ "$KEEP_RUNNING" -eq 0 ]; then + osascript -e 'tell application "Mojito Dev" to quit' >/dev/null 2>&1 + fi +} +trap cleanup EXIT + +# Post a batch of HID keystrokes and FAIL HARD if the helper errors — a silent +# no-op here is exactly how the harness would end up testing nothing. +drive() { "$CGTYPE" "$@" || { fail "cgtype failed: $*"; exit 2; }; } + +# ---- preflight ------------------------------------------------------------- +say "Preflight" +if ! open -Ra "Arc" 2>/dev/null && ! mdfind "kMDItemCFBundleIdentifier == '$ARC_BUNDLE'" | grep -q .; then + fail "Arc not installed (bundle $ARC_BUNDLE)."; exit 2 +fi + +# ---- build from this worktree (unless told not to) ------------------------- +if [ "$DO_BUILD" -eq 1 ]; then + say "Building Mojito Dev (Debug) from $REPO_ROOT" + BUILD_LOG="$(mktemp)" + if ! xcodebuild -project "$REPO_ROOT/Mojito.xcodeproj" -scheme Mojito \ + -configuration Debug -destination 'platform=macOS' build >"$BUILD_LOG" 2>&1; then + fail "Build failed. Tail:"; tail -20 "$BUILD_LOG" >&2; exit 2 + fi + BPD="$(xcodebuild -project "$REPO_ROOT/Mojito.xcodeproj" -scheme Mojito \ + -configuration Debug -destination 'platform=macOS' -showBuildSettings 2>/dev/null \ + | awk -F' = ' '/ BUILT_PRODUCTS_DIR /{print $2; exit}')" + rm -f "$BUILD_LOG" + DEV_APP="$BPD/Mojito Dev.app" + ok "Built: $DEV_APP" +fi +[ -z "$DEV_APP" ] && DEV_APP="/Applications/Mojito Dev.app" +if [ ! -x "$DEV_APP/Contents/MacOS/Mojito Dev" ]; then + fail "No runnable Mojito Dev at: $DEV_APP"; exit 2 +fi + +# ---- compile the HID keystroke helper -------------------------------------- +# HID-level CGEvents, NOT AppleScript keystroke: the latter does not traverse +# Mojito's session event tap, so it can't drive the code under test. +CGTYPE="$(mktemp -t cgtype)" +if ! swiftc "$(dirname "$0")/cgtype.swift" -o "$CGTYPE" 2>/dev/null; then + fail "Could not compile cgtype.swift (need the Xcode/Swift toolchain)."; exit 2 +fi + +# ---- relaunch Mojito Dev with E2E logging ---------------------------------- +say "Relaunching Mojito Dev with MOJITO_E2E_LOG=1" +if PRIOR_ENV="$(launchctl getenv MOJITO_E2E_LOG 2>/dev/null)" && [ -n "$PRIOR_ENV" ]; then + HAD_PRIOR_ENV=1 +fi +osascript -e 'tell application "Mojito Dev" to quit' >/dev/null 2>&1 +pkill -x "Mojito Dev" 2>/dev/null +sleep 1 +# Inject the env via launchd, then launch through LaunchServices (`open`): a +# directly-exec'd bundle binary doesn't come up as a proper GUI app (no +# NSWorkspace notifications). TCC grants key off the code signature, so the +# "Mojito Dev" identity's Accessibility / Input Monitoring still apply. +launchctl setenv MOJITO_E2E_LOG 1 +open "$DEV_APP" +sleep "$SETTLE_SECS" +pgrep -x "Mojito Dev" >/dev/null || { fail "Mojito Dev did not stay running."; exit 2; } +ok "Mojito Dev running (pid $(pgrep -x 'Mojito Dev' | head -1))" + +# Everything from here is timestamped; read the log back over this window +# (log show, not a streamed pipe — no attach race, no truncated tail). +T0="$(date -v-2S '+%Y-%m-%d %H:%M:%S')" +capture() { "$LOG" show --start "$T0" --style compact --predicate "$PRED" 2>/dev/null; } + +# ---- PROBE: prove synthetic keystrokes reach Mojito's tap ------------------ +say "Probe: verifying HID keystrokes reach Mojito's engine" +osascript -e 'tell application "Arc" to activate' >/dev/null 2>&1 +sleep 1 +drive hotkey command t +sleep 0.4 +drive type "$PROBE" +sleep 0.3 +drive key 53 +sleep 1.5 +if [ "$(capture | grep -c 'engine colon')" -lt 1 ]; then + fail "Inconclusive — the probe ':' never reached Mojito (no 'engine colon')." + echo " Synthetic keystrokes aren't hitting the tap. Likely: Mojito Dev" >&2 + echo " lacks Accessibility/Input Monitoring, or the driving terminal" >&2 + echo " lacks Accessibility to post HID events. Grant in System Settings." >&2 + exit 2 +fi +ok "Probe reached the engine — keystrokes are live" + +# ---- drive Arc ------------------------------------------------------------- +say "Driving Arc: $CYCLES new-tab cycles, typing \"$PHRASE\"" +for i in $(seq 1 "$CYCLES"); do + osascript -e 'tell application "Arc" to activate' >/dev/null 2>&1 + sleep 0.4 + drive hotkey command t # new tab → Arc's command bar (the busy-render window) + sleep 0.4 + drive type "$PHRASE" # words + spaces → the ambient-emoticon detect() path + sleep 0.5 + drive key 53 # escape → dismiss the command bar + printf ' cycle %d/%d\n' "$i" "$CYCLES" + sleep 0.3 +done +sleep 2 # let trailing log lines persist + +# ---- analyze --------------------------------------------------------------- +say "Analyzing" +CAP="$(capture)" +TOTAL_EVENTS=$(printf '%s\n' "$CAP" | grep -c "e2e") +TIMEOUTS=$(printf '%s\n' "$CAP" | grep "keyMonitor tapLost" | grep -c "reason=timeout") + +echo " e2e log lines: $TOTAL_EVENTS" +echo " tapLost timeouts: $TIMEOUTS" + +if [ "$TIMEOUTS" -gt 0 ]; then + fail "$TIMEOUTS event-tap timeout(s) while typing in Arc — keystrokes were dropped." + printf '%s\n' "$CAP" | grep "keyMonitor tapLost" | tail -20 >&2 + exit 1 +fi + +ok "No event-tap timeouts across $CYCLES Arc typing cycles (probe confirmed live). Healthy." +exit 0 diff --git a/scripts/e2e/cgtype.swift b/scripts/e2e/cgtype.swift new file mode 100644 index 0000000..0e9e465 --- /dev/null +++ b/scripts/e2e/cgtype.swift @@ -0,0 +1,85 @@ +// Synthetic keystroke helper for the E2E harness. +// +// Must post at the HID level (`.cghidEventTap`): Mojito's key monitor is a +// `.cgSessionEventTap`, and events synthesized by AppleScript "System Events +// keystroke" do NOT traverse it — they never reach Mojito's engine, so they +// can't reproduce a tap-path bug. HID-posted CGEvents do. Requires the invoking +// process to hold Accessibility permission. +// +// Usage: +// cgtype type "some text" Type Unicode text (one char per key event). +// cgtype hotkey command t Post a modified keystroke (mod: command|shift|option|control). +// cgtype key 53 Post a raw virtual keycode (53 = escape). + +import CoreGraphics +import Foundation + +let src = CGEventSource(stateID: .hidSystemState) + +// Only the letters the harness needs for hotkeys. +let keymap: [Character: CGKeyCode] = ["t": 17, "w": 13, "n": 45, "l": 37, "a": 0] + +// Track failed event creation so a caller (the harness) can tell that a keystroke +// was silently dropped rather than assume it typed — a no-op here is exactly how +// an E2E run would end up testing nothing. +var failures = 0 + +func post(_ event: CGEvent?) { + guard let event else { failures += 1; return } + event.post(tap: .cghidEventTap) + usleep(12_000) // ~12ms between events: fast typing, but not a burst the OS coalesces +} + +func typeText(_ text: String) { + for ch in text { + let units = Array(String(ch).utf16) + for isDown in [true, false] { + let e = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: isDown) + units.withUnsafeBufferPointer { + e?.keyboardSetUnicodeString(stringLength: $0.count, unicodeString: $0.baseAddress) + } + post(e) // nil is counted as a failure + } + } +} + +func hotkey(mod: CGEventFlags, key: CGKeyCode) { + for isDown in [true, false] { + let e = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: isDown) + e?.flags = isDown ? mod : [] + post(e) + } +} + +func rawKey(_ code: CGKeyCode) { + for isDown in [true, false] { + post(CGEvent(keyboardEventSource: src, virtualKey: code, keyDown: isDown)) + } +} + +let args = CommandLine.arguments +func die(_ msg: String) -> Never { + FileHandle.standardError.write(Data((msg + "\n").utf8)); exit(2) +} + +switch args.count >= 2 ? args[1] : "" { +case "type": + guard args.count >= 3 else { die("cgtype type ") } + typeText(args[2]) +case "hotkey": + guard args.count >= 4, let key = keymap[Character(args[3].lowercased())] else { + die("cgtype hotkey ") + } + let mod: CGEventFlags = ["command": .maskCommand, "shift": .maskShift, + "option": .maskAlternate, "control": .maskControl][args[2]] ?? [] + hotkey(mod: mod, key: key) +case "key": + guard args.count >= 3, let code = UInt16(args[2]) else { die("cgtype key ") } + rawKey(CGKeyCode(code)) +default: + die("usage: cgtype type | hotkey | key ") +} + +// Non-zero if any event couldn't be created — lets the harness treat a silent +// keystroke drop as a hard failure instead of a passing no-op. +exit(failures > 0 ? 1 : 0)