From 66cf6d95bcf82b10758d788fac6f364d799d7804 Mon Sep 17 00:00:00 2001 From: Wells Riley <884715+wr@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:40:56 -0400 Subject: [PATCH 1/5] W-555: read Arc's tab URL off the main thread so typing isn't blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing a word then a terminator (space/./,/!/?) in Arc's command bar stalled: the first press after the word did nothing, a second was needed. Cause: an ambient-emoticon terminator (`<3`→❤️ check) makes Engine.process call AppContextDetector.current() for a fresh per-site exclusion check, which calls BrowserURL.detect. Arc hides its web a11y tree, so every AX path returns nil and detect fell through to a synchronous NSAppleScript round-trip to Arc. That whole chain runs *inside the CGEventTap callback* (synchronous on the main run loop). A busy Arc replies slowly, the callback blows past the tap timeout, macOS fires tapDisabledByTimeout and drops the keystroke. Only Arc hit this — it's the sole browser on the AppleScript path. Same failure class as the W-547 typing stall. Fix: never run the Apple Event on the main/tap thread. New BrowserURLCache resolves Arc's URL on a background serial queue and serves the last value synchronously; detect() returns the cached URL and schedules a refresh for next time. Prefetches on app activation, and only serves a URL for the pid it was resolved from so a stale value can't cross an app switch. Mirrors FocusedElementCache's off-main-seed pattern. Staleness is bounded to about one focus-change/keystroke, fine for per-site exclusion matching, and it fixes all six BrowserURL.detect call sites, not just the emoticon one. Verified: Debug build succeeds; full 266-test suite green. Refs: W-555 --- Sources/Mojito/Context/BrowserURL.swift | 127 ++++++++++++++++++++---- 1 file changed, 107 insertions(+), 20 deletions(-) diff --git a/Sources/Mojito/Context/BrowserURL.swift b/Sources/Mojito/Context/BrowserURL.swift index d7865da..50d548d 100644 --- a/Sources/Mojito/Context/BrowserURL.swift +++ b/Sources/Mojito/Context/BrowserURL.swift @@ -31,29 +31,22 @@ enum BrowserURL { // 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 + // + // Crucially the AppleScript is NOT run here: `detect` is called from + // inside the CGEventTap callback (see `Engine.process`), and a + // synchronous Apple Event round-trip to a busy Arc blows past the tap + // timeout, so macOS disables the tap and drops the keystroke — every + // word+space stalled Arc's command bar (W-555). Instead we serve Arc's + // URL from an async cache refreshed off the main thread, the same way + // `FocusedElementCache` moved slow AX IPC off the tap thread (W-547). + // Bounded staleness (≈ one focus-change / keystroke) is fine for + // per-site exclusion matching. + if BrowserURLCache.appleScriptBundleIDs.contains(bundleID) { + return BrowserURLCache.shared.url(forBundleID: bundleID, pid: pid) } 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 +172,104 @@ enum BrowserURL { return result == .success ? ref : nil } - private static func normalizedURL(from raw: String) -> URL? { + nonisolated 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) } } + +/// Async cache for browsers whose tab URL is only readable via AppleScript +/// (Arc). The AppleScript round-trip must never run on the main thread: it is +/// reached from `BrowserURL.detect`, which the engine calls synchronously +/// inside the CGEventTap callback, and a slow reply trips the tap timeout and +/// drops the keystroke (W-555). So the Apple Event runs on a background serial +/// queue, and `url(forBundleID:pid:)` returns the last resolved value +/// immediately while kicking off a refresh for next time. +/// +/// Same shape as `FocusedElementCache`: slow cross-process IPC off-main, a +/// synchronous cheap read on the hot path, staleness bounded by refreshing on +/// app activation and after every read. The value is only ever served for the +/// pid it was resolved from, so a stale URL can't bleed across an app switch. +@MainActor +final class BrowserURLCache { + static let shared = BrowserURLCache() + + static let appleScriptBundleIDs: Set = [ + "company.thebrowser.Browser", // Arc + ] + + /// Last resolved URL and the pid + bundleID it belongs to. `nil` url is a + /// real answer (no window / denied Automation / non-URL value) and is + /// cached as such so we don't refetch it every keystroke. + private var cachedURL: URL? + private var cachedPID: pid_t? + private var haveResult = false + + /// One refresh in flight at a time — coalesces the burst of reads a single + /// word+terminator produces into one Apple Event. + private var refreshing = false + + /// AppleScript off the main thread. Serial so a hung Arc can't overlap + /// round-trips, and its one-time Automation prompt blocks this queue, not + /// the main run loop. + private static let queue = DispatchQueue(label: "mojito.browserURL.applescript", qos: .userInitiated) + + private init() { + // Prefetch on activation so switching to Arc and immediately typing has + // a warm URL rather than a first-keystroke miss. + 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.refresh(bundleID: bundleID, pid: app.processIdentifier) + } + } + } + + /// Cheap synchronous read for the hot path. Returns the cached URL only if + /// it belongs to this pid, and always schedules a 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 + refresh(bundleID: bundleID, pid: pid) + return value + } + + private func refresh(bundleID: String, pid: pid_t) { + guard !refreshing else { return } + refreshing = true + Self.queue.async { + let url = Self.appleScriptURL(bundleID: bundleID) + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.cachedURL = url + self.cachedPID = pid + self.haveResult = true + 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. + /// `nonisolated` so it runs on `queue`, off the main thread. + private nonisolated 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) + } +} From 33eeaa4df468c6ec967074d6e25c807905da1cd3 Mon Sep 17 00:00:00 2001 From: Wells Riley <884715+wr@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:03:16 -0400 Subject: [PATCH 2/5] =?UTF-8?q?W-555:=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=20main-thread=20AppleScript=20+=20skip=20Arc=20AX=20w?= =?UTF-8?q?alk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-provider (Codex) review of the first cut found the async cache moved NSAppleScript to a background GCD queue, but NSAppleScript is documented main-thread-only — off-main it can hang or misbehave, which would also wedge the single-flight `refreshing` flag forever. It also left the synchronous AX tree walk on the tap thread for Arc even though that walk always returns nil there. Rework: - detect(): for AppleScript-only browsers (Arc) branch straight to the cache before the AX walk, so no cross-process AX IPC runs on the tap thread for Arc at all. - BrowserURLCache: run NSAppleScript on the MAIN thread (where it's supported) but via DispatchQueue.main.async so it lands on its own run-loop turn, never inside the CGEventTap callback. A 1s throttle keeps refreshes from recurring per keystroke; app-activation refreshes bypass it. The scheduled block always clears `refreshing`, so a failed/timed-out script can't wedge the cache (fixes the stuck-single-flight finding). Hot-path read still does zero IPC — returns cached, schedules a refresh. pid-guarded so a stale URL can't cross an app switch. Verified: Debug build succeeds; full suite green (266 tests, 20 suites). Refs: W-555 --- Sources/Mojito/Context/BrowserURL.swift | 142 +++++++++++++----------- 1 file changed, 79 insertions(+), 63 deletions(-) diff --git a/Sources/Mojito/Context/BrowserURL.swift b/Sources/Mojito/Context/BrowserURL.swift index 50d548d..4941ff6 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,29 +41,6 @@ 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. - // - // Crucially the AppleScript is NOT run here: `detect` is called from - // inside the CGEventTap callback (see `Engine.process`), and a - // synchronous Apple Event round-trip to a busy Arc blows past the tap - // timeout, so macOS disables the tap and drops the keystroke — every - // word+space stalled Arc's command bar (W-555). Instead we serve Arc's - // URL from an async cache refreshed off the main thread, the same way - // `FocusedElementCache` moved slow AX IPC off the tap thread (W-547). - // Bounded staleness (≈ one focus-change / keystroke) is fine for - // per-site exclusion matching. - if BrowserURLCache.appleScriptBundleIDs.contains(bundleID) { - return BrowserURLCache.shared.url(forBundleID: bundleID, pid: pid) - } return nil } @@ -172,7 +169,7 @@ enum BrowserURL { return result == .success ? ref : nil } - nonisolated fileprivate 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) } @@ -180,18 +177,24 @@ enum BrowserURL { } } -/// Async cache for browsers whose tab URL is only readable via AppleScript -/// (Arc). The AppleScript round-trip must never run on the main thread: it is -/// reached from `BrowserURL.detect`, which the engine calls synchronously -/// inside the CGEventTap callback, and a slow reply trips the tap timeout and -/// drops the keystroke (W-555). So the Apple Event runs on a background serial -/// queue, and `url(forBundleID:pid:)` returns the last resolved value -/// immediately while kicking off a refresh for next time. +/// 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. /// -/// Same shape as `FocusedElementCache`: slow cross-process IPC off-main, a -/// synchronous cheap read on the hot path, staleness bounded by refreshing on -/// app activation and after every read. The value is only ever served for the -/// pid it was resolved from, so a stale URL can't bleed across an app switch. +/// 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() @@ -200,21 +203,23 @@ final class BrowserURLCache { "company.thebrowser.Browser", // Arc ] - /// Last resolved URL and the pid + bundleID it belongs to. `nil` url is a - /// real answer (no window / denied Automation / non-URL value) and is - /// cached as such so we don't refetch it every keystroke. + /// 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 - /// One refresh in flight at a time — coalesces the burst of reads a single - /// word+terminator produces into one Apple Event. + /// 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 - /// AppleScript off the main thread. Serial so a hung Arc can't overlap - /// round-trips, and its one-time Automation prompt blocks this queue, not - /// the main run loop. - private static let queue = DispatchQueue(label: "mojito.browserURL.applescript", qos: .userInitiated) + /// 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 static let minRefreshInterval: TimeInterval = 1.0 private init() { // Prefetch on activation so switching to Arc and immediately typing has @@ -228,34 +233,45 @@ final class BrowserURLCache { guard let app = NSWorkspace.shared.frontmostApplication, let bundleID = app.bundleIdentifier, BrowserURLCache.appleScriptBundleIDs.contains(bundleID) else { return } - BrowserURLCache.shared.refresh(bundleID: bundleID, pid: app.processIdentifier) + BrowserURLCache.shared.scheduleRefresh( + bundleID: bundleID, pid: app.processIdentifier, force: true + ) } } } - /// Cheap synchronous read for the hot path. Returns the cached URL only if - /// it belongs to this pid, and always schedules a 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. + /// 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 - refresh(bundleID: bundleID, pid: pid) + scheduleRefresh(bundleID: bundleID, pid: pid, force: false) return value } - private func refresh(bundleID: String, pid: pid_t) { + /// 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, + Date().timeIntervalSince(last) < Self.minRefreshInterval { + return + } refreshing = true - Self.queue.async { - let url = Self.appleScriptURL(bundleID: bundleID) - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.cachedURL = url - self.cachedPID = pid - self.haveResult = true - self.refreshing = false - } + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.cachedURL = Self.appleScriptURL(bundleID: bundleID) + self.cachedPID = pid + self.haveResult = true + self.lastRefreshAt = Date() + self.refreshing = false } } } @@ -263,8 +279,8 @@ final class BrowserURLCache { /// `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. - /// `nonisolated` so it runs on `queue`, off the main thread. - private nonisolated static func appleScriptURL(bundleID: String) -> URL? { + /// 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? From 077fe804d33ae447f3d3e2002f881de7102e3de8 Mon Sep 17 00:00:00 2001 From: Wells Riley <884715+wr@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:20:16 -0400 Subject: [PATCH 3/5] W-555: automated tests for the non-blocking Arc URL contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a test seam to BrowserURLCache (inject resolver + clock, skip the workspace observer) and a BrowserURLCacheTests suite that pins the property whose violation caused the bug: the hot-path read does no blocking IPC and defers the AppleScript to a later main-thread turn. Covered deterministically (no live Arc / AppleScript / app switch): - cold read returns nil and does NOT resolve synchronously — the resolver runs once, on a later turn, on the main thread (regression guard for AppleScript creeping back onto the tap thread) - cached value served for the same pid; withheld across a pid change - a burst of reads collapses to one resolve (single-flight) - throttle skips a refresh within the interval, allows it after (injected clock) - hot-path read returns in <50ms even when the resolver blocks 200ms The true end-to-end "keys don't drop in Arc" is timing/Arc-dependent and can't be asserted deterministically; these nail the cause instead. Suite is green: 273 tests, 21 suites. Refs: W-555 --- Sources/Mojito/Context/BrowserURL.swift | 37 ++++- Tests/MojitoTests/BrowserURLCacheTests.swift | 162 +++++++++++++++++++ 2 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 Tests/MojitoTests/BrowserURLCacheTests.swift diff --git a/Sources/Mojito/Context/BrowserURL.swift b/Sources/Mojito/Context/BrowserURL.swift index 4941ff6..9a601d2 100644 --- a/Sources/Mojito/Context/BrowserURL.swift +++ b/Sources/Mojito/Context/BrowserURL.swift @@ -197,7 +197,12 @@ enum BrowserURL { /// can't bleed across an app switch. @MainActor final class BrowserURLCache { - static let shared = 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 @@ -219,11 +224,29 @@ final class BrowserURLCache { /// 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 static let minRefreshInterval: TimeInterval = 1.0 + private let minRefreshInterval: TimeInterval - private init() { + /// 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. + // 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, @@ -260,17 +283,17 @@ final class BrowserURLCache { private func scheduleRefresh(bundleID: String, pid: pid_t, force: Bool) { guard !refreshing else { return } if !force, haveResult, let last = lastRefreshAt, - Date().timeIntervalSince(last) < Self.minRefreshInterval { + now().timeIntervalSince(last) < minRefreshInterval { return } refreshing = true DispatchQueue.main.async { [weak self] in MainActor.assumeIsolated { guard let self else { return } - self.cachedURL = Self.appleScriptURL(bundleID: bundleID) + self.cachedURL = self.resolver(bundleID) self.cachedPID = pid self.haveResult = true - self.lastRefreshAt = Date() + self.lastRefreshAt = self.now() self.refreshing = false } } 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)) + } +} From 7aa131e8fadcbc6404e698e36b9fc1f7cb40114a Mon Sep 17 00:00:00 2001 From: Wells Riley <884715+wr@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:01:05 -0400 Subject: [PATCH 4/5] W-556: Arc typing E2E regression harness + os_log E2E hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arc keeps generating bug reports (W-555 latest) and its event-tap / AX behavior can't be exercised by unit tests. Add a live end-to-end harness that drives real Arc + a real Mojito build and asserts the tap stays healthy. - DebugRecorder: when launched with MOJITO_E2E_LOG=1, mirror every recorded activity event into the unified log (subsystem ee.wells.Mojito, category e2e). Off by default — the logger isn't even constructed without the env var — so production is untouched. Values are already ASCII-clamped, safe to log %{public} so `log show` can read them back. Makes the tapLost / focus / picker stream queryable headlessly. - scripts/e2e/arc-typing-regression.sh: relaunch Mojito Dev with the flag, type terminator-heavy phrases into fresh Arc tabs, assert zero `keyMonitor tapLost reason=timeout`. Exit 0 healthy / 1 bug / 2 inconclusive (no e2e activity is reported, never a false pass). - scripts/e2e/cgtype.swift: HID-level CGEvent keystroke helper. Critical: AppleScript `keystroke` does NOT traverse Mojito's session tap, so it can't drive the code under test; HID CGEvents do (verified — HID `:tada` fires engine colon / picker open, System Events fires nothing). - Docs: scripts/e2e/README.md + a CLAUDE.md Testing subsection, incl. the gotchas (HID events, /usr/bin/log vs the zsh builtin, launchctl+open vs direct-exec). Validated on this machine as far as possible without Arc installed: the os_log hook emits real events, /usr/bin/log capture works, the dev build's TCC grants are live, and cgtype-driven keystrokes reach Mojito's engine (engine colon / picker open) with zero tap timeouts on a healthy path. The Arc-specific new-tab run needs a Mac with Arc. Unit suite green (273/21). Refs: W-556 --- CLAUDE.md | 12 ++ Sources/Mojito/Debug/DebugRecorder.swift | 24 ++++ scripts/e2e/README.md | 65 +++++++++ scripts/e2e/arc-typing-regression.sh | 171 +++++++++++++++++++++++ scripts/e2e/cgtype.swift | 75 ++++++++++ 5 files changed, 347 insertions(+) create mode 100644 scripts/e2e/README.md create mode 100755 scripts/e2e/arc-typing-regression.sh create mode 100644 scripts/e2e/cgtype.swift 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/Debug/DebugRecorder.swift b/Sources/Mojito/Debug/DebugRecorder.swift index 2453b5a..5e34701 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,20 @@ 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. Recorded values are already + /// anonymized (ASCII-clamped, no user text), so logging them `%{public}` — + /// required to read them back with `log show` — leaks nothing. + 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 +88,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/scripts/e2e/README.md b/scripts/e2e/README.md new file mode 100644 index 0000000..e044e7a --- /dev/null +++ b/scripts/e2e/README.md @@ -0,0 +1,65 @@ +# 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 stream 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. + +## Requirements (the harness checks, but can't grant) + +- **Arc installed.** +- **A Mojito Dev build** (`xcodebuild -scheme Mojito -configuration Debug`), + with Accessibility **and** 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 # 5 cycles, default phrase +scripts/e2e/arc-typing-regression.sh --cycles 10 +scripts/e2e/arc-typing-regression.sh --app "/Applications/Mojito Dev.app" +``` + +Exit: `0` healthy, `1` timeouts observed (bug), `2` inconclusive/precondition +(e.g. Arc missing, or no e2e activity captured — which is 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..7a7eeb5 --- /dev/null +++ b/scripts/e2e/arc-typing-regression.sh @@ -0,0 +1,171 @@ +#!/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. Relaunch the Mojito Dev build 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. Capture that log stream while typing terminator-heavy phrases into fresh +# Arc tabs (Cmd+T), repeated to provoke the busy-new-tab stall. +# 3. Assert ZERO `keyMonitor tapLost reason=timeout` events in the window — +# that event IS the dropped-keystroke bug. A run that records no e2e events +# at all is reported inconclusive (app not hooked / TCC not granted), never +# a false pass. +# +# Requirements (the harness can check but not grant): +# - Arc installed. +# - A Mojito Dev build (xcodebuild -scheme Mojito -configuration Debug) with +# Accessibility + Input Monitoring granted to "Mojito Dev". +# - The terminal/agent driving this has Accessibility (to synthesize keystrokes +# via System Events). +# +# Usage: +# scripts/e2e/arc-typing-regression.sh [--cycles N] [--phrase "text"] [--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" +KEEP_RUNNING=0 +DEV_APP="/Applications/Mojito Dev.app" +DEV_BIN="$DEV_APP/Contents/MacOS/Mojito Dev" +ARC_BUNDLE="company.thebrowser.Browser" +SUBSYSTEM="ee.wells.Mojito" +SETTLE_SECS=3 + +while [ $# -gt 0 ]; do + case "$1" in + --cycles) CYCLES="$2"; shift 2 ;; + --phrase) PHRASE="$2"; shift 2 ;; + --app) DEV_APP="$2"; DEV_BIN="$DEV_APP/Contents/MacOS/Mojito Dev"; shift 2 ;; + --keep) KEEP_RUNNING=1; shift ;; + -h|--help) sed -n '2,32p' "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +LOGFILE="$(mktemp -t mojito-e2e-arc)" +STREAM_PID="" +CGTYPE="" + +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' "$*"; } + +# `log` is a shell builtin in zsh — shadowing /usr/bin/log yields empty output. +LOG=/usr/bin/log + +cleanup() { + [ -n "$STREAM_PID" ] && kill "$STREAM_PID" 2>/dev/null + [ -n "$CGTYPE" ] && rm -f "$CGTYPE" + launchctl unsetenv MOJITO_E2E_LOG 2>/dev/null + if [ "$KEEP_RUNNING" -eq 0 ]; then + osascript -e 'tell application "Mojito Dev" to quit' >/dev/null 2>&1 + fi +} +trap cleanup EXIT + +# ---- 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 +if [ ! -x "$DEV_BIN" ]; then + fail "Mojito Dev not built. Run: xcodebuild -project Mojito.xcodeproj -scheme Mojito -configuration Debug -destination 'platform=macOS' build"; exit 2 +fi +ok "Arc present; Mojito Dev present" + +# ---- (re)launch Mojito Dev with E2E logging -------------------------------- +say "Launching Mojito Dev with MOJITO_E2E_LOG=1" +# Quit any running dev instance so we control the env of the one under test. +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`), not +# direct-exec: a directly-exec'd bundle binary doesn't come up as a proper GUI +# app (no NSWorkspace notifications → no activity to observe). TCC grants key off +# the code signature, so the "Mojito Dev" identity's Accessibility / Input +# Monitoring still apply. The launchd var is cleared in cleanup. +launchctl setenv MOJITO_E2E_LOG 1 +open "$DEV_APP" +sleep "$SETTLE_SECS" +if ! pgrep -x "Mojito Dev" >/dev/null; then + fail "Mojito Dev did not stay running after launch."; exit 2 +fi +ok "Mojito Dev running (pid $(pgrep -x 'Mojito Dev' | head -1))" + +# ---- capture the unified log ---------------------------------------------- +say "Capturing e2e log" +"$LOG" stream --style compact \ + --predicate "subsystem == \"$SUBSYSTEM\" && category == \"e2e\"" \ + > "$LOGFILE" 2>/dev/null & +STREAM_PID=$! +sleep 2 # let the stream attach + +# ---- build the keystroke helper ------------------------------------------- +# HID-level CGEvents, NOT AppleScript keystroke: the latter does not traverse +# Mojito's session event tap, so it can't drive (or stress) 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 + +# ---- drive Arc ------------------------------------------------------------- +say "Driving Arc: $CYCLES new-tab cycles, typing \"$PHRASE\"" +osascript -e 'tell application "Arc" to activate' >/dev/null 2>&1 +sleep 1 +for i in $(seq 1 "$CYCLES"); do + osascript -e 'tell application "Arc" to activate' >/dev/null 2>&1 + sleep 0.4 + "$CGTYPE" hotkey command t # new tab → Arc's command bar (the busy-render window) + sleep 0.4 + "$CGTYPE" type "$PHRASE" # words + spaces → the ambient-emoticon detect() path + sleep 0.5 + "$CGTYPE" key 53 # escape → dismiss the command bar + printf ' cycle %d/%d\n' "$i" "$CYCLES" + sleep 0.3 +done +sleep 2 # let trailing log lines flush + +# ---- analyze --------------------------------------------------------------- +say "Analyzing" +kill "$STREAM_PID" 2>/dev/null; STREAM_PID="" + +# grep -c always prints a count (0 when no match); no `|| echo 0` — that would +# append a second "0" and break the numeric tests below. +TOTAL_EVENTS=$(grep -c "e2e" "$LOGFILE" 2>/dev/null) +ARC_SEEN=$(grep -c "company.thebrowser.Browser" "$LOGFILE" 2>/dev/null) +TIMEOUTS=$(grep "keyMonitor tapLost" "$LOGFILE" 2>/dev/null | grep -c "reason=timeout") + +echo " e2e log lines: $TOTAL_EVENTS" +echo " Arc-attributed lines: $ARC_SEEN" +echo " tapLost timeouts: $TIMEOUTS" +echo " (full log: $LOGFILE)" + +# No events at all → the app wasn't logging/hooked. Don't call that a pass. +if [ "$TOTAL_EVENTS" -eq 0 ] || [ "$ARC_SEEN" -eq 0 ]; then + fail "Inconclusive — no e2e activity captured for Arc." + echo " Likely: Mojito Dev lacks Accessibility/Input Monitoring, or the" >&2 + echo " driving terminal lacks Accessibility to synthesize keystrokes." >&2 + echo " Grant in System Settings > Privacy & Security, then rerun." >&2 + exit 2 +fi + +if [ "$TIMEOUTS" -gt 0 ]; then + fail "$TIMEOUTS event-tap timeout(s) while typing in Arc — keystrokes were dropped." + grep "keyMonitor tapLost" "$LOGFILE" | tail -20 >&2 + exit 1 +fi + +ok "No event-tap timeouts across $CYCLES Arc typing cycles. Typing path is healthy." +exit 0 diff --git a/scripts/e2e/cgtype.swift b/scripts/e2e/cgtype.swift new file mode 100644 index 0000000..7dea986 --- /dev/null +++ b/scripts/e2e/cgtype.swift @@ -0,0 +1,75 @@ +// 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] + +func post(_ event: CGEvent?) { + 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] { + guard let e = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: isDown) else { continue } + units.withUnsafeBufferPointer { + e.keyboardSetUnicodeString(stringLength: $0.count, unicodeString: $0.baseAddress) + } + post(e) + } + } +} + +func hotkey(mod: CGEventFlags, key: CGKeyCode) { + for isDown in [true, false] { + guard let e = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: isDown) else { continue } + 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 ") +} From 5b39ae18e9051e44de62d3f586c284536e1398d7 Mon Sep 17 00:00:00 2001 From: Wells Riley <884715+wr@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:30:03 -0400 Subject: [PATCH 5/5] =?UTF-8?q?W-556:=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=20make=20the=20harness=20unable=20to=20pass=20falsely?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-provider (Codex) review flagged that the harness could report a green run while testing nothing. Fixes: - Positive keystroke proof (was: "saw an Arc log line"). Activating Arc alone emits a focus event, so that was no proof anything typed. Now the harness probes with a `:` trigger and REQUIRES an `engine colon` event before trusting the run; no probe hit → inconclusive (exit 2), never a pass. - Build from this worktree by default (--no-build/--app to override) so a regressed-but-unrebuilt binary can't sneak a stale green through. - Fail hard on driver errors: validate --cycles is a positive int; cgtype now exits non-zero if it can't post an event, and every drive step is checked (a silent no-op was the other route to a false pass). - Read the log via `log show` over a recorded time window instead of a streamed pipe — no attach race, no truncated tail. - Preserve/restore the prior MOJITO_E2E_LOG session value in cleanup. - DebugRecorder: correct the comment — the ASCII clamp bounds spillage but is NOT anonymization; %{public} is safe only because call sites pass non-sensitive metadata. Validated: `log show` capture + the probe detect `engine colon`; cgtype compiles and reports failures; bad --cycles and missing Arc exit 2. Unit suite green (273/21). The Arc-specific run still needs a Mac with Arc. Refs: W-556 --- Sources/Mojito/Debug/DebugRecorder.swift | 12 +- scripts/e2e/README.md | 36 +++-- scripts/e2e/arc-typing-regression.sh | 198 +++++++++++++---------- scripts/e2e/cgtype.swift | 22 ++- 4 files changed, 165 insertions(+), 103 deletions(-) diff --git a/Sources/Mojito/Debug/DebugRecorder.swift b/Sources/Mojito/Debug/DebugRecorder.swift index 5e34701..e25bbea 100644 --- a/Sources/Mojito/Debug/DebugRecorder.swift +++ b/Sources/Mojito/Debug/DebugRecorder.swift @@ -49,9 +49,15 @@ enum DebugRecorder { /// 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. Recorded values are already - /// anonymized (ASCII-clamped, no user text), so logging them `%{public}` — - /// required to read them back with `log show` — leaks nothing. + /// 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") diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index e044e7a..2032f33 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -16,31 +16,47 @@ disable the tap by timeout and **drop keystrokes** (W-555). 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 stream 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. +`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.** -- **A Mojito Dev build** (`xcodebuild -scheme Mojito -configuration Debug`), - with Accessibility **and** Input Monitoring granted to `Mojito Dev.app`. Grants - persist across rebuilds because the "Mojito Dev" signing identity is stable. +- **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 # 5 cycles, default phrase +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 -(e.g. Arc missing, or no e2e activity captured — which is reported, never a false -pass). +(Arc missing, build failed, or the probe never reached the tap — all reported, +never a false pass). ## Gotchas baked in (learned the hard way) diff --git a/scripts/e2e/arc-typing-regression.sh b/scripts/e2e/arc-typing-regression.sh index 7a7eeb5..a39bb4e 100755 --- a/scripts/e2e/arc-typing-regression.sh +++ b/scripts/e2e/arc-typing-regression.sh @@ -9,25 +9,29 @@ # Arc + a real Mojito build and asserts the symptom is absent. # # How it works: -# 1. Relaunch the Mojito Dev build with MOJITO_E2E_LOG=1, which mirrors the -# in-app DebugRecorder activity log into the unified log (subsystem +# 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. Capture that log stream while typing terminator-heavy phrases into fresh -# Arc tabs (Cmd+T), repeated to provoke the busy-new-tab stall. -# 3. Assert ZERO `keyMonitor tapLost reason=timeout` events in the window — -# that event IS the dropped-keystroke bug. A run that records no e2e events -# at all is reported inconclusive (app not hooked / TCC not granted), never -# a false pass. +# 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 (the harness can check but not grant): +# Requirements (checked, but can't be granted by the script): # - Arc installed. -# - A Mojito Dev build (xcodebuild -scheme Mojito -configuration Debug) with -# Accessibility + Input Monitoring granted to "Mojito Dev". -# - The terminal/agent driving this has Accessibility (to synthesize keystrokes -# via System Events). +# - 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"] [--keep] +# 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. @@ -36,136 +40,162 @@ 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 -DEV_APP="/Applications/Mojito Dev.app" -DEV_BIN="$DEV_APP/Contents/MacOS/Mojito Dev" +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"; DEV_BIN="$DEV_APP/Contents/MacOS/Mojito Dev"; shift 2 ;; - --keep) KEEP_RUNNING=1; shift ;; - -h|--help) sed -n '2,32p' "$0"; exit 0 ;; + --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 -LOGFILE="$(mktemp -t mojito-e2e-arc)" -STREAM_PID="" +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' "$*"; } -# `log` is a shell builtin in zsh — shadowing /usr/bin/log yields empty output. -LOG=/usr/bin/log - cleanup() { - [ -n "$STREAM_PID" ] && kill "$STREAM_PID" 2>/dev/null [ -n "$CGTYPE" ] && rm -f "$CGTYPE" - launchctl unsetenv MOJITO_E2E_LOG 2>/dev/null + # 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 -if [ ! -x "$DEV_BIN" ]; then - fail "Mojito Dev not built. Run: xcodebuild -project Mojito.xcodeproj -scheme Mojito -configuration Debug -destination 'platform=macOS' build"; exit 2 + +# ---- 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 -ok "Arc present; Mojito Dev present" -# ---- (re)launch Mojito Dev with E2E logging -------------------------------- -say "Launching Mojito Dev with MOJITO_E2E_LOG=1" -# Quit any running dev instance so we control the env of the one under test. +# ---- 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`), not -# direct-exec: a directly-exec'd bundle binary doesn't come up as a proper GUI -# app (no NSWorkspace notifications → no activity to observe). TCC grants key off -# the code signature, so the "Mojito Dev" identity's Accessibility / Input -# Monitoring still apply. The launchd var is cleared in cleanup. +# 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" -if ! pgrep -x "Mojito Dev" >/dev/null; then - fail "Mojito Dev did not stay running after launch."; exit 2 -fi +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))" -# ---- capture the unified log ---------------------------------------------- -say "Capturing e2e log" -"$LOG" stream --style compact \ - --predicate "subsystem == \"$SUBSYSTEM\" && category == \"e2e\"" \ - > "$LOGFILE" 2>/dev/null & -STREAM_PID=$! -sleep 2 # let the stream attach +# 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; } -# ---- build the keystroke helper ------------------------------------------- -# HID-level CGEvents, NOT AppleScript keystroke: the latter does not traverse -# Mojito's session event tap, so it can't drive (or stress) 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 +# ---- 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\"" -osascript -e 'tell application "Arc" to activate' >/dev/null 2>&1 -sleep 1 for i in $(seq 1 "$CYCLES"); do osascript -e 'tell application "Arc" to activate' >/dev/null 2>&1 sleep 0.4 - "$CGTYPE" hotkey command t # new tab → Arc's command bar (the busy-render window) + drive hotkey command t # new tab → Arc's command bar (the busy-render window) sleep 0.4 - "$CGTYPE" type "$PHRASE" # words + spaces → the ambient-emoticon detect() path + drive type "$PHRASE" # words + spaces → the ambient-emoticon detect() path sleep 0.5 - "$CGTYPE" key 53 # escape → dismiss the command bar + 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 flush +sleep 2 # let trailing log lines persist # ---- analyze --------------------------------------------------------------- say "Analyzing" -kill "$STREAM_PID" 2>/dev/null; STREAM_PID="" - -# grep -c always prints a count (0 when no match); no `|| echo 0` — that would -# append a second "0" and break the numeric tests below. -TOTAL_EVENTS=$(grep -c "e2e" "$LOGFILE" 2>/dev/null) -ARC_SEEN=$(grep -c "company.thebrowser.Browser" "$LOGFILE" 2>/dev/null) -TIMEOUTS=$(grep "keyMonitor tapLost" "$LOGFILE" 2>/dev/null | grep -c "reason=timeout") - -echo " e2e log lines: $TOTAL_EVENTS" -echo " Arc-attributed lines: $ARC_SEEN" -echo " tapLost timeouts: $TIMEOUTS" -echo " (full log: $LOGFILE)" - -# No events at all → the app wasn't logging/hooked. Don't call that a pass. -if [ "$TOTAL_EVENTS" -eq 0 ] || [ "$ARC_SEEN" -eq 0 ]; then - fail "Inconclusive — no e2e activity captured for Arc." - echo " Likely: Mojito Dev lacks Accessibility/Input Monitoring, or the" >&2 - echo " driving terminal lacks Accessibility to synthesize keystrokes." >&2 - echo " Grant in System Settings > Privacy & Security, then rerun." >&2 - exit 2 -fi +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." - grep "keyMonitor tapLost" "$LOGFILE" | tail -20 >&2 + printf '%s\n' "$CAP" | grep "keyMonitor tapLost" | tail -20 >&2 exit 1 fi -ok "No event-tap timeouts across $CYCLES Arc typing cycles. Typing path is healthy." +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 index 7dea986..0e9e465 100644 --- a/scripts/e2e/cgtype.swift +++ b/scripts/e2e/cgtype.swift @@ -19,8 +19,14 @@ 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?) { - event?.post(tap: .cghidEventTap) + 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 } @@ -28,19 +34,19 @@ func typeText(_ text: String) { for ch in text { let units = Array(String(ch).utf16) for isDown in [true, false] { - guard let e = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: isDown) else { continue } + let e = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: isDown) units.withUnsafeBufferPointer { - e.keyboardSetUnicodeString(stringLength: $0.count, unicodeString: $0.baseAddress) + e?.keyboardSetUnicodeString(stringLength: $0.count, unicodeString: $0.baseAddress) } - post(e) + post(e) // nil is counted as a failure } } } func hotkey(mod: CGEventFlags, key: CGKeyCode) { for isDown in [true, false] { - guard let e = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: isDown) else { continue } - e.flags = isDown ? mod : [] + let e = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: isDown) + e?.flags = isDown ? mod : [] post(e) } } @@ -73,3 +79,7 @@ case "key": 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)