Files
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

208 lines
11 KiB
Swift

#if DEBUG
import AppKit
import EdmundCore
/// In-process repro driver: `-debug.reproScript <path>` replays a keystroke
/// script against the front document through the real AppKit key-event path
/// (window.sendEvent → keyDown → interpretKeyEvents → insertText /
/// deleteBackward). Exists because TCC-denied automation sessions cannot post
/// CGEvents at the app; this keeps live-app bug repros scriptable without
/// Accessibility permission. Commands, one per line:
/// sleep <ms> wait before the next command
/// caret <needle> place the caret before the first occurrence of <needle>
/// type <text> type text, one key event per character
/// backspace <n> press delete n times (300ms apart)
/// tab / backtab indent / dedent the selected list line(s)
/// scroll <y> scroll the clip view to y (bypasses the caret/typewriter
/// recentering, so a block can be driven off-screen)
/// logsel log the current selection
@MainActor
enum ReproScript {
static func runIfRequested() {
guard let path = UserDefaults.standard.string(forKey: "debug.reproScript"),
let script = try? String(contentsOfFile: path, encoding: .utf8) else { return }
Log.info("repro script: \(path)", category: .app)
var delay: TimeInterval = 1.5 // let the document finish opening
for line in script.split(separator: "\n") {
let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
guard let cmd = parts.first, !cmd.hasPrefix("#") else { continue }
let arg = parts.count > 1 ? parts[1] : ""
switch cmd {
case "sleep":
delay += (Double(arg) ?? 0) / 1000
case "caret":
schedule(after: delay) { editor in
let r = (editor.rawSource as NSString).range(of: arg)
guard r.location != NSNotFound else {
Log.info("repro caret: needle not found: \(arg)", category: .app)
return
}
editor.setSelectedRange(NSRange(location: r.location, length: 0))
}
case "caretoff":
// Absolute-offset caret move (arrow-key-like: fromMouse=false).
schedule(after: delay) { editor in
let n = min(Int(arg) ?? 0, (editor.rawSource as NSString).length)
editor.setSelectedRange(NSRange(location: n, length: 0))
}
case "clickoff":
// Absolute-offset caret move on the MOUSE path: sets
// suppressTypewriterCentering for the selection change so the
// +SelectionTracking restyle captures fromMouse=true and takes
// the preservingViewportAnchor branch (what a real click does).
schedule(after: delay) { editor in
editor.reproClickSelect(Int(arg) ?? 0)
}
case "realclickoff":
// Absolute-offset caret move via a REAL synthesized mouse click
// at the glyph's on-screen position: goes through hit-testing and
// NSTextView.mouseDown, the genuine mouse path (fromMouse=true),
// which programmatic setSelectedRange does not replicate. Needed
// because faithful keystroke replay alone does not arm the
// round-7 drift — the arming caret moves were real clicks.
schedule(after: delay) { editor in
let n = min(Int(arg) ?? 0, (editor.rawSource as NSString).length)
var actual = NSRange()
let scr = editor.firstRect(forCharacterRange: NSRange(location: n, length: 0),
actualRange: &actual)
guard let screen = editor.window?.screen else { return }
// firstRect: Cocoa screen coords (origin bottom-left). CGEvent
// wants top-left origin.
let cocoaPt = CGPoint(x: scr.midX, y: scr.midY)
let p = CGPoint(x: cocoaPt.x, y: screen.frame.maxY - cocoaPt.y)
func post(_ t: CGEventType) {
CGEvent(mouseEventSource: nil, mouseType: t, mouseCursorPosition: p,
mouseButton: .left)?.post(tap: .cghidEventTap)
}
post(.mouseMoved); post(.leftMouseDown); post(.leftMouseUp)
}
case "selrange":
// "selrange N M" — select M chars at offset N.
schedule(after: delay) { editor in
let f = arg.split(separator: " ")
guard f.count == 2, let n = Int(f[0]), let m = Int(f[1]) else { return }
editor.setSelectedRange(NSRange(location: n, length: m))
}
case "caretend":
// Place the caret at the very end of the document (the phantom
// empty final line when rawSource ends in "\n"). Needles can't
// target an empty line, so this is the only way to sit there.
schedule(after: delay) { editor in
let end = (editor.rawSource as NSString).length
editor.setSelectedRange(NSRange(location: end, length: 0))
}
case "type":
for ch in arg {
let s = String(ch)
// Direct action call (not a synthesized NSEvent through the
// input context): the storage mutation + queued-fixup path is
// identical, but avoids the input-context fragility that a
// long scripted replay hits when programmatic selections and
// synthetic key events interleave.
schedule(after: delay) { $0.insertText(s, replacementRange: NSRange(location: NSNotFound, length: 0)) }
delay += 0.08
}
case "backspace":
for _ in 0 ..< (Int(arg) ?? 1) {
schedule(after: delay) { $0.deleteBackward(nil) }
delay += 0.3
}
case "return":
schedule(after: delay) { $0.insertText("\n", replacementRange: NSRange(location: NSNotFound, length: 0)) }
delay += 0.05
case "tab":
schedule(after: delay) { $0.insertTab(nil) }
delay += 0.05
case "backtab":
schedule(after: delay) { $0.insertBacktab(nil) }
delay += 0.05
case "bypassdelete":
// Mimics AppKit's drag-move source deletion (the issue-#156
// trigger): select the range, run shouldChangeText and the
// storage mutation, and never call didChangeText — the
// bypassed-edit heal then fires on the next run-loop pass.
schedule(after: delay) { editor in
let r = (editor.rawSource as NSString).range(of: arg)
guard r.location != NSNotFound else {
Log.info("repro bypassdelete: needle not found: \(arg)", category: .app)
return
}
editor.setSelectedRange(r)
guard editor.shouldChangeText(in: r, replacementString: "") else { return }
editor.textStorage?.replaceCharacters(in: r, with: "")
}
case "bypassoff":
// "bypassoff N M" — offset form of bypassdelete: delete M chars
// at N via shouldChangeText + storage mutation, no didChangeText.
schedule(after: delay) { editor in
let f = arg.split(separator: " ")
guard f.count == 2, let n = Int(f[0]), let m = Int(f[1]) else { return }
let r = NSRange(location: n, length: m)
editor.setSelectedRange(r)
guard editor.shouldChangeText(in: r, replacementString: "") else { return }
editor.textStorage?.replaceCharacters(in: r, with: "")
}
case "assertcaret":
// PASS iff the caret sits exactly before the first occurrence
// of <needle> — position-independent drift check for soaks.
schedule(after: delay) { editor in
let want = (editor.rawSource as NSString).range(of: arg).location
let sel = editor.selectedRange()
let ok = sel.location == want && sel.length == 0
Log.info("repro assertcaret \(ok ? "PASS" : "FAIL") " +
"sel=\(sel) want=\(want) needle=\(arg)", category: .app)
}
case "logsel":
schedule(after: delay) { editor in
Log.info("repro logsel sel=\(editor.selectedRange()) " +
"rawLen=\((editor.rawSource as NSString).length) " +
"docs=\(NSDocumentController.shared.documents.count)",
category: .app)
}
case "scroll":
// Scrolls the clip view directly (bypassing the caret, so the
// active block can be driven off-screen independent of where
// typewriter-mode recentering would otherwise put it).
// `scroll(to:)` posts boundsDidChange, same as a real drag/wheel
// scroll, so promotion/idle-drain react exactly as they would live.
schedule(after: delay) { editor in
guard let clipView = editor.enclosingScrollView?.contentView else { return }
let y = CGFloat(Double(arg) ?? 0)
let proposed = NSRect(origin: NSPoint(x: 0, y: y), size: clipView.bounds.size)
let clamped = clipView.constrainBoundsRect(proposed)
clipView.scroll(to: clamped.origin)
editor.enclosingScrollView?.reflectScrolledClipView(clipView)
Log.info("repro scroll y=\(y) clamped=\(clamped.origin.y)", category: .app)
}
default:
break
}
delay += 0.02
}
}
private static func schedule(after: TimeInterval,
_ body: @escaping @MainActor (EditorTextView) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + after) {
guard let doc = NSDocumentController.shared.documents.first as? Document,
let editor = doc.editor else { return }
body(editor)
}
}
/// Sends a key event through the window so it takes the full AppKit
/// keyDown route, exactly like a physical keystroke.
private static func press(_ chars: String, keyCode: UInt16, in editor: EditorTextView) {
guard let window = editor.window,
let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber, context: nil,
characters: chars, charactersIgnoringModifiers: chars,
isARepeat: false, keyCode: keyCode) else { return }
window.makeFirstResponder(editor)
window.sendEvent(event)
}
}
#endif