Files
cmux/cmuxTests/ViewerNavigationTests.swift
T
Lawrence Chen b129a8669f Move diff viewer backend boundary to a Rust sidecar (#7804)
* Add Rust diff viewer sidecar

* Harden diff sidecar request handling

* Close sidecar review gaps

* Finish sidecar build and retry integration

* Gate sidecar transport and webview checks

* Remove sidecar setup and localization gaps

* Extract diff sidecar process boundary

* Use stdio for native diff sidecar transport

* Satisfy Swift file length guard

* Make custom scheme test deterministic

* Address sidecar review findings

* Test incremental diff tree source reuse

* Make diff tree streaming linear

* Verify diff correctness and streaming performance

* Fix diff sidecar review regressions

* Harden diff sidecar stdio RPC

* Test bounded large diff rendering

* Bound large diff UI updates

* Harden large diff navigation

* Fix diff sidecar isolation warning

* Test mobile diff drawer close control

* Make mobile diff drawer opaque

* Harden mobile diff drawer dismissal

* Refactor diff viewer bridge ownership

* Preserve diff sidecar pipe ownership

* Load diff sessions lazily through Rust

* Keep Rust diff sessions alive while rendering

* Split diff sidecar helpers from legacy files

* Close diff sessions before page navigation

* Close diff sessions before navigating

* Track active diff sessions through navigation

* Refresh generated diff viewer bundle

* Keep diff source switching responsive

* Open typed diff sessions in place

* Update diff CLI file budget

* Extract typed diff viewer writer

* Build typed diff writer in CLI target

* Expose shared diff shortcut payload

* Share typed diff writer model types

* Allow typed diff fallback input replacement

* Open diff loading shell before asset setup

* Bound typed branch base resolution

* Avoid duplicate diff theme registration

* Test custom-scheme asset fetch decoding

* Decode deflated assets for diff scheme

* Test cancellation of stale diff streams

* Cancel stale diff sessions and cap patch writes

* test: cover diff sidecar review regressions

* fix: bound diff sidecar lifecycle

* test: cover sidecar cancellation cleanup

* fix: clean up cancelled sidecar process groups

* test: require race-free sidecar process groups

* fix: handshake sidecar process group startup

* test: cover cancellation after patch rename

* fix: retain cleanup ownership through registration

* fix: bound sidecar startup and shutdown

* test: cover branch picker repository switches

* fix: close final sidecar lifecycle gaps

* test: cover same-repo branch base changes

* fix: preserve process group identity through shutdown

* Make stale branch picker test state-driven

* Test Last Turn switching and abandoned sidecar sessions

* Keep typed diff sources and manifests recoverable

* Test typed diff selector composition

* Compose typed diff selector state

* Rebuild diff webview assets

* Test orphan cleanup and Last Turn repo switching

* Close typed diff lifecycle gaps

* Test pending cancellation and rotating orphan cleanup

* Bound pending and remote diff resources

* Cap sidecar queue and index temp cleanup

* Bound server sessions and retain patch ownership

* Make patch ownership and HTTP encoding durable

* Test empty branch base selection

* Keep empty branch and pending patch recovery available

* Test branch base survives source switching

* Preserve selected branch base across source switches

* Retain generated patch ownership until lifecycle cleanup

* Serialize token session publication

* Keep concurrent diff sessions independently owned

* Reconcile session cleanup with manifest lifecycle

* Make session publication cancellation safe

* Scope cancellation and close transactions correctly

* Authorize session close by manifest ownership

* Close discarded diff sessions safely

* Cancel superseded diff sessions safely

* Reserve diff session resources atomically

* Protect active diff session patches

* Preserve active typed diff sessions

* Lease active diff sidecar sessions

* Journal diff session resource ownership

* Bound diff session recovery artifacts

* Harden diff sidecar production artifact

* Fix POSIX lock calls on Xcode 26.5

* Fix app-side lease locking on Xcode 26.5

* test: cover typed diff direct page lifecycle

* fix: open typed diff session page directly

* Fix sidecar verification for spaced paths
2026-07-15 22:00:34 -07:00

459 lines
19 KiB
Swift

import AppKit
import CmuxSettings
import Testing
import WebKit
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
@MainActor
@Suite(.serialized)
struct ViewerNavigationTests {
@Test
func nativeViewerRouterAppliesClausesAndPreservesChords() {
let router = ViewerNavigationKeyRouter(actions: [
.diffViewerScrollDown,
.diffViewerScrollToTop,
])
var performed: [KeyboardShortcutSettings.Action] = []
let denied = router.handle(Self.keyEvent("j"), isAllowed: { _, _ in false }, perform: { performed.append($0) })
#expect(!denied)
#expect(performed.isEmpty)
#expect(router.handle(Self.keyEvent("g", timestamp: 10), isAllowed: { _, _ in true }, perform: { performed.append($0) }))
#expect(router.handle(Self.keyEvent("g", timestamp: 10.1), isAllowed: { _, _ in true }, perform: { performed.append($0) }))
#expect(performed == [.diffViewerScrollToTop])
}
@Test
func diffViewerNavigationStateRestoresOnlyAfterCancelledNavigation() {
let state = DiffViewerNavigationDocumentState()
let firstNavigationObject = NSObject()
let secondNavigationObject = NSObject()
let firstNavigation = ObjectIdentifier(firstNavigationObject)
let secondNavigation = ObjectIdentifier(secondNavigationObject)
state.update(viewer: true, editable: false, rendererReady: true)
#expect(state.canHandleNavigation)
state.navigationDidStart(id: firstNavigation)
#expect(!state.canHandleNavigation)
state.navigationDidCancel(id: firstNavigation)
#expect(state.canHandleNavigation)
state.navigationDidStart(id: firstNavigation)
state.navigationDidStart(id: secondNavigation)
state.navigationDidCancel(id: firstNavigation)
#expect(!state.canHandleNavigation)
state.navigationDidCancel(id: secondNavigation)
#expect(state.canHandleNavigation)
state.navigationDidStart(id: firstNavigation)
state.navigationDidCommit(id: firstNavigation)
state.navigationDidCancel(id: firstNavigation)
#expect(!state.canHandleNavigation)
}
@Test
func tokenShapedURLWithoutActiveSessionIsNotTrusted() throws {
let inactiveToken = UUID().uuidString.lowercased()
let url = try #require(URL(
string: "http://127.0.0.1:5050/\(inactiveToken)/diff.html#cmux-diff-viewer"
))
#expect(DiffCommentsBridge.diffViewerToken(from: url) != nil)
#expect(!DiffCommentsBridge.isTrustedDiffViewerURL(url))
}
@Test
func registeredLiveHTTPViewerIsTrustedOnlyOnItsOrigin() throws {
let liveToken = UUID().uuidString.lowercased()
let original = try #require(URL(
string: "http://127.0.0.1:5050/\(liveToken)/diff.html#cmux-diff-viewer"
))
let rewritten = try #require(URL(
string: "http://127.0.0.1:5050/\(liveToken)/diff.html#/cmux-diff-viewer"
))
let wrongPort = try #require(URL(
string: "http://127.0.0.1:5051/\(liveToken)/diff.html#/cmux-diff-viewer"
))
#expect(DiffViewerSessionTrustRegistry.shared.registerLiveHTTPURL(original, token: liveToken))
#expect(DiffCommentsBridge.isTrustedDiffViewerURL(rewritten))
#expect(!DiffCommentsBridge.isTrustedDiffViewerURL(wrongPort))
}
@Test
func sidecarBridgeRequiresRegisteredCustomSchemeViewerURL() throws {
let token = UUID().uuidString.lowercased()
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-sidecar-bridge-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let html = root.appendingPathComponent("index.html", isDirectory: false)
try "<html></html>".write(to: html, atomically: true, encoding: .utf8)
try CmuxDiffViewerURLSchemeHandler.shared.register(
token: token,
files: [.init(requestPath: "/index.html", fileURL: html, mimeType: "text/html")]
)
let customSchemeURL = try #require(URL(string: "cmux-diff-viewer://\(token)/index.html"))
let loopbackLookalikeURL = try #require(URL(string: "http://127.0.0.1:5050/\(token)/index.html"))
#expect(DiffSidecarBridge.isTrustedSidecarURL(customSchemeURL))
#expect(!DiffSidecarBridge.isTrustedSidecarURL(loopbackLookalikeURL))
}
@Test
func sidecarProcessPoolCancelsQueuedWorkWithoutLeakingPermit() async throws {
let pool = DiffSidecarProcessPool(limit: 1)
let counter = SidecarPoolTestCounter()
let firstStarted = AsyncStream<Void>.makeStream()
let releaseFirst = AsyncStream<Void>.makeStream()
var firstStartedIterator = firstStarted.stream.makeAsyncIterator()
let first = Task {
try await pool.withPermit {
await counter.increment()
firstStarted.continuation.yield()
for await _ in releaseFirst.stream {
return
}
}
}
_ = await firstStartedIterator.next()
let cancelled = Task {
try await pool.withPermit {
await counter.increment()
}
}
cancelled.cancel()
releaseFirst.continuation.yield()
releaseFirst.continuation.finish()
try await first.value
await #expect(throws: CancellationError.self) {
try await cancelled.value
}
#expect(await counter.value == 1)
try await pool.withPermit {
await counter.increment()
}
#expect(await counter.value == 2)
}
@Test
func registeredLiveHTTPViewerTrustRenewsWhileSessionRemainsActive() throws {
let liveToken = UUID().uuidString.lowercased()
let url = try #require(URL(
string: "http://127.0.0.1:5050/\(liveToken)/diff.html#cmux-diff-viewer"
))
let registeredAt = Date(timeIntervalSince1970: 1_000)
#expect(DiffViewerSessionTrustRegistry.shared.registerLiveHTTPURL(
url,
token: liveToken,
now: registeredAt
))
#expect(DiffViewerSessionTrustRegistry.shared.isTrustedDiffViewerURL(
url,
now: registeredAt.addingTimeInterval(23 * 60 * 60)
))
#expect(DiffViewerSessionTrustRegistry.shared.isTrustedDiffViewerURL(
url,
now: registeredAt.addingTimeInterval(46 * 60 * 60)
))
}
@Test
func viewerEmacsBindingsDoNotConflictWithCommandPaletteNavigation() {
let next = KeyboardShortcutSettings.Action.commandPaletteNext.defaultShortcut
let previous = KeyboardShortcutSettings.Action.commandPalettePrevious.defaultShortcut
#expect(KeyboardShortcutSettings.Action.commandPaletteNext.shortcutContext == .commandPaletteVisible)
#expect(KeyboardShortcutSettings.Action.commandPalettePrevious.shortcutContext == .commandPaletteVisible)
#expect(!KeyboardShortcutSettings.Action.diffViewerScrollDownEmacs.conflicts(
with: next,
proposedAction: .commandPaletteNext,
configuredShortcut: KeyboardShortcutSettings.Action.diffViewerScrollDownEmacs.defaultShortcut
))
#expect(!KeyboardShortcutSettings.Action.diffViewerScrollUpEmacs.conflicts(
with: previous,
proposedAction: .commandPalettePrevious,
configuredShortcut: KeyboardShortcutSettings.Action.diffViewerScrollUpEmacs.defaultShortcut
))
}
@Test
func commandPaletteNavigationHonorsConfiguredWhenClause() throws {
let appDelegate = try #require(AppDelegate.shared)
let originalStore = KeyboardShortcutSettings.settingsFileStore
let originalCache = appDelegate.shortcutEventFocusContextCache
let settingsURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-palette-navigation-\(UUID().uuidString).json")
try """
{
"shortcuts": {
"when": {
"commandPaletteNext": "commandPaletteVisible && paneCount > 1"
}
}
}
""".write(to: settingsURL, atomically: true, encoding: .utf8)
KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore(
primaryPath: settingsURL.path,
fallbackPath: nil,
additionalFallbackPaths: [],
startWatching: false
)
defer {
appDelegate.shortcutEventFocusContextCache = originalCache
KeyboardShortcutSettings.settingsFileStore = originalStore
try? FileManager.default.removeItem(at: settingsURL)
}
let event = Self.keyEvent("n", modifiers: .control)
var shortcutContext = ShortcutContext()
shortcutContext.setBool("commandPaletteVisible", true)
shortcutContext.setInt("paneCount", 1)
appDelegate.shortcutEventFocusContextCache = ShortcutEventFocusContextCache(
event: event,
context: ShortcutEventFocusContext(
browserPanel: nil,
markdownPanel: nil,
filePreviewTextEditorFocused: false,
rightSidebarFocused: false,
shortcutContext: shortcutContext
)
)
#expect(contextAwareCommandPaletteSelectionDelta(for: event) == nil)
}
@Test
func markdownViewerUsesSmoothVimAndEmacsNavigation() async throws {
let appDelegate = try #require(AppDelegate.shared)
let frame = NSRect(x: 0, y: 0, width: 720, height: 360)
let webView = MarkdownWebView(frame: frame, configuration: WKWebViewConfiguration())
let window = NSWindow(contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false)
window.contentView = webView
window.orderFrontRegardless()
defer {
webView.navigationDelegate = nil
window.close()
}
let loadDelegate = ViewerNavigationShellLoadDelegate()
webView.navigationDelegate = loadDelegate
try await loadDelegate.load(
MarkdownViewerAssets.shared.shellHTML(isDark: true),
in: webView,
baseURL: FileManager.default.temporaryDirectory.appendingPathComponent("navigation.md")
)
try await renderMarkdown(scrollSmokeMarkdown(), in: webView)
try await webView.evaluateJavaScript(
"""
(function() {
var scroller = document.scrollingElement || document.documentElement;
window.__cmuxNativeNavigationCalls = [];
scroller.scrollTo = function(options) { window.__cmuxNativeNavigationCalls.push(options); };
})();
"""
)
let domKeyCallCount = try #require(
try await webView.evaluateJavaScript(
"document.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', bubbles: true })); window.__cmuxNativeNavigationCalls.length"
) as? NSNumber
)
#expect(domKeyCallCount.intValue == 0)
#expect(handleViewerNavigationKey(Self.keyEvent("j"), in: webView, appDelegate: appDelegate))
#expect(handleViewerNavigationKey(Self.keyEvent("d", modifiers: .control), in: webView, appDelegate: appDelegate))
#expect(handleViewerNavigationKey(Self.keyEvent("p", modifiers: .control), in: webView, appDelegate: appDelegate))
#expect(handleViewerNavigationKey(Self.keyEvent("g"), in: webView, appDelegate: appDelegate))
#expect(handleViewerNavigationKey(Self.keyEvent("g"), in: webView, appDelegate: appDelegate))
#expect(!handleViewerNavigationKey(Self.keyEvent("x"), in: webView, appDelegate: appDelegate))
let nativeCalls = try #require(
try await webView.evaluateJavaScript("window.__cmuxNativeNavigationCalls") as? [[String: Any]]
)
#expect(nativeCalls.count == 4)
#expect(nativeCalls.map { $0["behavior"] as? String } == ["smooth", "smooth", "smooth", "smooth"])
#expect((nativeCalls[0]["top"] as? NSNumber)?.doubleValue == 72)
#expect(
(nativeCalls[1]["top"] as? NSNumber)?.doubleValue ?? 0
> ((nativeCalls[0]["top"] as? NSNumber)?.doubleValue ?? .greatestFiniteMagnitude)
)
#expect(
(nativeCalls[2]["top"] as? NSNumber)?.doubleValue ?? .greatestFiniteMagnitude
< ((nativeCalls[1]["top"] as? NSNumber)?.doubleValue ?? 0)
)
#expect((nativeCalls[3]["top"] as? NSNumber)?.doubleValue == 0)
_ = try await webView.evaluateJavaScript("window.__cmuxNativeNavigationCalls = []")
#expect(handleViewerNavigationKey(Self.keyEvent("g", timestamp: 10), in: webView, appDelegate: appDelegate))
#expect(handleViewerNavigationKey(Self.keyEvent("g", timestamp: 11), in: webView, appDelegate: appDelegate))
let staleChordCalls = try #require(
try await webView.evaluateJavaScript("window.__cmuxNativeNavigationCalls") as? [[String: Any]]
)
#expect(staleChordCalls.isEmpty, "an expired chord must not navigate")
#expect(handleViewerNavigationKey(Self.keyEvent("g", timestamp: 11.1), in: webView, appDelegate: appDelegate))
let expiredChordCalls = try #require(
try await webView.evaluateJavaScript("window.__cmuxNativeNavigationCalls") as? [[String: Any]]
)
#expect(expiredChordCalls.count == 1)
#expect((expiredChordCalls[0]["top"] as? NSNumber)?.doubleValue == 0)
_ = try await webView.evaluateJavaScript(
"""
(() => {
const editor = document.createElement('div');
editor.contentEditable = 'true';
document.body.appendChild(editor);
editor.focus();
})();
"""
)
for _ in 0..<20 where !webView.isViewerNavigationEditableElementFocused {
await Task.yield()
}
#expect(webView.isViewerNavigationEditableElementFocused)
#expect(!webView.handleViewerNavigationKey(Self.keyEvent("j")))
}
@Test
func markdownViewerHonorsConfiguredWhenClause() throws {
let appDelegate = try #require(AppDelegate.shared)
let originalStore = KeyboardShortcutSettings.settingsFileStore
let originalCache = appDelegate.shortcutEventFocusContextCache
let settingsURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-markdown-navigation-\(UUID().uuidString).json")
try """
{
"shortcuts": {
"when": {
"diffViewerScrollDown": "terminalFocus"
}
}
}
""".write(to: settingsURL, atomically: true, encoding: .utf8)
KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore(
primaryPath: settingsURL.path,
fallbackPath: nil,
additionalFallbackPaths: [],
startWatching: false
)
defer {
appDelegate.shortcutEventFocusContextCache = originalCache
KeyboardShortcutSettings.settingsFileStore = originalStore
try? FileManager.default.removeItem(at: settingsURL)
}
let event = Self.keyEvent("j")
installMarkdownFocusContext(for: event, appDelegate: appDelegate)
let webView = MarkdownWebView(frame: .zero, configuration: WKWebViewConfiguration())
#expect(!webView.handleViewerNavigationKey(event))
}
private func handleViewerNavigationKey(
_ event: NSEvent,
in webView: MarkdownWebView,
appDelegate: AppDelegate
) -> Bool {
installMarkdownFocusContext(for: event, appDelegate: appDelegate)
return webView.handleViewerNavigationKey(event)
}
private func installMarkdownFocusContext(for event: NSEvent, appDelegate: AppDelegate) {
var shortcutContext = ShortcutContext()
shortcutContext.setBool("markdownFocus", true)
appDelegate.shortcutEventFocusContextCache = ShortcutEventFocusContextCache(
event: event,
context: ShortcutEventFocusContext(
browserPanel: nil,
markdownPanel: nil,
filePreviewTextEditorFocused: false,
rightSidebarFocused: false,
shortcutContext: shortcutContext
)
)
}
private func renderMarkdown(_ markdown: String, in webView: WKWebView) async throws {
let data = try JSONSerialization.data(withJSONObject: [markdown])
let literal = try #require(String(data: data, encoding: .utf8))
_ = try await webView.evaluateJavaScript("window.__cmuxRenderMarkdown(\(literal)[0]);")
}
private func scrollSmokeMarkdown() -> String {
(1...36).map { section in
"## Section \(section)\n\n" + (1...5).map { paragraph in
"Paragraph \(paragraph) for section \(section). This gives the renderer enough height to exercise viewer navigation."
}.joined(separator: "\n\n")
}.joined(separator: "\n\n")
}
private static func keyEvent(
_ characters: String,
modifiers: NSEvent.ModifierFlags = [],
timestamp: TimeInterval = 0
) -> NSEvent {
NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: modifiers,
timestamp: timestamp,
windowNumber: 0,
context: nil,
characters: characters,
charactersIgnoringModifiers: characters,
isARepeat: false,
keyCode: 0
)!
}
}
private actor SidecarPoolTestCounter {
private(set) var value = 0
func increment() {
value += 1
}
}
private final class ViewerNavigationShellLoadDelegate: NSObject, WKNavigationDelegate {
private var continuation: CheckedContinuation<Void, Error>?
func load(_ html: String, in webView: WKWebView, baseURL: URL) async throws {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
webView.loadHTMLString(html, baseURL: baseURL)
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
finish(.success(()))
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
finish(.failure(error))
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
finish(.failure(error))
}
private func finish(_ result: Result<Void, Error>) {
guard let continuation else { return }
self.continuation = nil
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}