From d7f5ba3b4f4745660dbb5ada6fde6f841aba07af Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:32:06 +0200 Subject: [PATCH] macOS: add test cases for ScriptKeyEventCommand Co-Authored-By: Claude Fable 5 --- .../AppleScript/ScriptKeyEventCommand.swift | 70 ++++-- .../ScriptKeyEventCommandTests.swift | 230 ++++++++++++++++++ 2 files changed, 285 insertions(+), 15 deletions(-) create mode 100644 macos/Tests/Features/AppleScript/ScriptKeyEventCommandTests.swift diff --git a/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift b/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift index 0091098c5..d7dcf9d68 100644 --- a/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift +++ b/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift @@ -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 } } diff --git a/macos/Tests/Features/AppleScript/ScriptKeyEventCommandTests.swift b/macos/Tests/Features/AppleScript/ScriptKeyEventCommandTests.swift new file mode 100644 index 000000000..31e907bb1 --- /dev/null +++ b/macos/Tests/Features/AppleScript/ScriptKeyEventCommandTests.swift @@ -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]) + } +}