macOS: add test cases for ScriptKeyEventCommand (#14008)

Partial changes for #13205, known issues are marked as warnings.

### AI Disclosure

Claude generated these tests from linked pr, I cherrypicked and reviewed
myself.
This commit is contained in:
Mitchell Hashimoto
2026-08-25 05:41:14 -07:00
committed by GitHub
2 changed files with 285 additions and 15 deletions

View File

@@ -11,12 +11,6 @@ final class ScriptKeyEventCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let keyName = directParameter as? String else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing key name."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
@@ -35,10 +29,61 @@ final class ScriptKeyEventCommand: NSScriptCommand {
return nil
}
guard let key = Ghostty.Input.Key(rawValue: keyName) else {
let keyEvent: Ghostty.Input.KeyEvent
do {
keyEvent = try Self.parse(
directParameter: directParameter,
evaluatedArguments: evaluatedArguments)
} catch ArgumentError.missingKey {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing key name."
return nil
} catch let ArgumentError.unknownKey(keyName) {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown key name: \(keyName)"
return nil
} catch let ArgumentError.unknownModifiers(modsString) {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown modifier in: \(modsString)"
return nil
} catch {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Invalid key event."
return nil
}
surface.sendKeyEvent(keyEvent)
return nil
}
}
extension ScriptKeyEventCommand {
enum ArgumentError: Error, Equatable {
case missingKey
case unknownKey(String)
case unknownModifiers(String)
}
/// Parse the scripting arguments for `send key` into the key event to
/// deliver to the surface.
///
/// - Parameters:
/// - directParameter: The command's direct parameter (the key name).
/// - evaluatedArguments: The command's evaluated arguments.
/// - translationMods: Maps the event's modifiers to the subset that
/// participates in text translation for the target surface.
static func parse(
directParameter: Any?,
evaluatedArguments: [String: Any]?,
translationMods: (Ghostty.Input.Mods) -> Ghostty.Input.Mods = { $0 },
) throws -> Ghostty.Input.KeyEvent {
guard let keyName = directParameter as? String else {
throw ArgumentError.missingKey
}
guard let key = Ghostty.Input.Key(rawValue: keyName) else {
throw ArgumentError.unknownKey(keyName)
}
let action: Ghostty.Input.Action
@@ -55,22 +100,17 @@ final class ScriptKeyEventCommand: NSScriptCommand {
let mods: Ghostty.Input.Mods
if let modsString = evaluatedArguments?["modifiers"] as? String {
guard let parsed = Ghostty.Input.Mods(scriptModifiers: modsString) else {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown modifier in: \(modsString)"
return nil
throw ArgumentError.unknownModifiers(modsString)
}
mods = parsed
} else {
mods = []
}
let keyEvent = Ghostty.Input.KeyEvent(
return Ghostty.Input.KeyEvent(
key: key,
action: action,
mods: mods
mods: mods,
)
surface.sendKeyEvent(keyEvent)
return nil
}
}

View File

@@ -0,0 +1,230 @@
import AppKit
import Testing
@testable import Ghostty
/// Tests for `ScriptKeyEventCommand.parse`, which turns the scripting
/// arguments of the `send key` AppleScript command into the
/// `Ghostty.Input.KeyEvent` delivered to the surface.
///
/// Printable keys must carry the text and codepoints a real NSEvent-based key
/// press would, derived from the current keyboard layout; otherwise the key
/// produces no terminal output. Expected characters are computed through
/// `KeyboardLayout` rather than hardcoded so the tests hold on any layout.
@MainActor
struct ScriptKeyEventCommandTests {
private let keyCodeA: UInt16 = 0x00 // W3C KeyA
/// Parse a `send key` command the way the scripting runtime delivers it.
private func parse(
_ keyName: Any?,
modifiers: String? = nil,
action: UInt32? = nil,
translationMods: (Ghostty.Input.Mods) -> Ghostty.Input.Mods = { $0 },
) throws -> Ghostty.Input.KeyEvent {
var arguments: [String: Any] = [:]
if let modifiers { arguments["modifiers"] = modifiers }
if let action { arguments["action"] = action }
return try ScriptKeyEventCommand.parse(
directParameter: keyName,
evaluatedArguments: arguments.isEmpty ? nil : arguments,
translationMods: translationMods,
)
}
// MARK: Argument parsing
@Test func defaultsToPressWithNoModifiers() throws {
let event = try parse("a")
#expect(event.key == .a)
#expect(event.action == .press)
#expect(event.mods == [])
}
@Test func parsesActionCodes() throws {
try #expect(
parse("enter", action: "GIpr".fourCharCode).action == .press
)
try #expect(
parse("enter", action: "GIrl".fourCharCode).action == .release
)
}
@Test func unknownActionCodeFallsBackToPress() throws {
try #expect(parse("a", action: 0).action == .press)
}
@Test(
arguments: [
("shift", Ghostty.Input.Mods.shift),
("control", .ctrl),
("option", .alt),
("command", .super),
("shift, command", [.shift, .super]),
("SHIFT,Option", [.shift, .alt]),
(" control , shift ", [.ctrl, .shift]),
("", []),
] as [(String, Ghostty.Input.Mods)]
)
func parsesModifiers(string: String, expected: Ghostty.Input.Mods) throws {
try #expect(parse("a", modifiers: string).mods == expected)
}
@Test(arguments: [nil, 42, NSNull()] as [Any?])
func missingOrNonStringKeyThrows(directParameter: Any?) {
#expect(throws: ScriptKeyEventCommand.ArgumentError.missingKey) {
try parse(directParameter)
}
}
@Test func unknownKeyNameThrows() {
#expect(
throws: ScriptKeyEventCommand.ArgumentError.unknownKey("banana")
) {
try parse("banana")
}
}
@Test func unknownModifierThrows() {
#expect(
throws: ScriptKeyEventCommand.ArgumentError.unknownModifiers(
"shift, hyper"
)
) {
try parse("a", modifiers: "shift, hyper")
}
}
/// The sdef's documented key name examples must all resolve.
@Test(arguments: ["enter", "a", "space"])
func documentedKeyNamesResolve(name: String) throws {
_ = try parse(name)
}
// MARK: Produced key event
// These record known issues until `send key` derives text and codepoints
// from the keyboard layout the way real NSEvent-based input does.
// Uncomment the expectations and remove the Issue.record calls once the
// fix lands.
@Test func pressCarriesLayoutText() throws {
let event = try parse("a")
// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
// #expect(event.text == String(expected))
// #expect(event.unshiftedCodepoint == expected.unicodeScalars.first?.value)
// #expect(event.consumedMods == [])
Issue.record(
"""
press should carry the layout's text and unshifted codepoint; \
got text \(String(describing: event.text)), \
codepoint \(event.unshiftedCodepoint)
""",
severity: .warning
)
}
@Test func shiftShiftsTextAndIsConsumed() throws {
let event = try parse("a", modifiers: "shift")
// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .shift))
// let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
// #expect(event.text == String(expected))
// #expect(event.consumedMods == .shift)
// #expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
Issue.record(
"""
shift should apply to the translated text and be consumed; \
got text \(String(describing: event.text))
""",
severity: .warning
)
}
/// The original bug scenario: `send key "c" with modifiers "control"`
/// must produce the base character as text ("c", not 0x03) with control
/// unconsumed, so core can encode the control sequence itself.
@Test func controlKeepsBaseTextAndIsNotConsumed() throws {
let event = try parse("c", modifiers: "control")
// let expected = try #require(KeyboardLayout.character(
// for: 0x08, // W3C KeyC
// modifiers: []))
// #expect(event.text == String(expected))
// #expect(event.mods == .ctrl)
// #expect(event.consumedMods == [])
Issue.record(
"""
ctrl should be stripped from translation ("c", not 0x03) and \
stay unconsumed; got text \(String(describing: event.text))
""",
severity: .warning
)
}
@Test func optionIncludedInTranslationIsConsumed() throws {
// macos-option-as-alt=false: option participates in translation.
let event = try parse("a", modifiers: "option")
// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: .option))
// #expect(event.text == String(expected))
// #expect(event.consumedMods == .alt)
Issue.record(
"""
option in the translation mods should apply to the text and be \
consumed; got text \(String(describing: event.text))
""",
severity: .warning
)
}
@Test func optionExcludedFromTranslationIsNotConsumed() throws {
// macos-option-as-alt=true: the surface's translation mods exclude
// option, so it stays unconsumed and core can encode it (e.g. ESC
// prefix).
let event = try parse("a", modifiers: "option") { $0.subtracting(.alt) }
// let expected = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
// #expect(event.text == String(expected))
// #expect(event.consumedMods == [])
Issue.record(
"""
option excluded from the translation mods should leave the base \
text and stay unconsumed; got text \(String(describing: event.text))
""",
severity: .warning
)
}
@Test func releaseCarriesNoText() throws {
let event = try parse(
"a",
modifiers: "shift",
action: "GIrl".fourCharCode
)
// let unshifted = try #require(KeyboardLayout.character(for: keyCodeA, modifiers: []))
// #expect(event.text == nil)
// #expect(event.unshiftedCodepoint == unshifted.unicodeScalars.first?.value)
Issue.record(
"""
release should carry no text but still report the unshifted \
codepoint; got codepoint \(event.unshiftedCodepoint)
""",
severity: .warning
)
}
/// Keys whose layout translation is a control character (or a PUA
/// function-key character) must carry no text and no unshifted codepoint;
/// core encodes them from the key enum.
@Test(arguments: [
Ghostty.Input.Key.enter, .escape, .tab, .backspace, .arrowUp, .f1,
.home, .delete,
])
func functionalKeysCarryNoTextOrCodepoint(key: Ghostty.Input.Key) throws {
let event = try parse(key.rawValue)
#expect(event.text == nil)
#expect(event.unshiftedCodepoint == 0)
}
@Test func modsPassThroughUnchanged() throws {
let event = try parse("a", modifiers: "control, shift")
#expect(event.mods == [.ctrl, .shift])
}
}