diff --git a/apps/headless/Sources/HeadlessProtocol/MenuShortcuts.swift b/apps/headless/Sources/HeadlessProtocol/MenuShortcuts.swift new file mode 100644 index 0000000..d8cbaab --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/MenuShortcuts.swift @@ -0,0 +1,121 @@ +import Foundation + +/// Portable catalog of macOS app menu shortcuts. The Cocoa host builds real +/// `NSMenuItem`s from these specs so chords, selectors, and targets stay one +/// source. The protocol suite rejects duplicate physical chords. +public enum MenuShortcutTarget: String, Equatable, Sendable { + case application + case appDelegate + case firstResponder +} + +public struct MenuShortcutSpec: Equatable, Sendable { + public let menu: String + public let title: String + public let key: String + public let command: Bool + public let shift: Bool + public let option: Bool + public let control: Bool + public let selector: String + public let target: MenuShortcutTarget + public let separatorBefore: Bool + + public init( + menu: String, + title: String, + key: String, + command: Bool = true, + shift: Bool = false, + option: Bool = false, + control: Bool = false, + selector: String, + target: MenuShortcutTarget = .firstResponder, + separatorBefore: Bool = false + ) { + self.menu = menu + self.title = title + self.key = key + self.command = command + self.shift = shift + self.option = option + self.control = control + self.selector = selector + self.target = target + self.separatorBefore = separatorBefore + } + + public var chordIdentity: String { + [ + command ? "cmd" : nil, + control ? "ctrl" : nil, + option ? "opt" : nil, + shift ? "shift" : nil, + key.isEmpty ? nil : key.lowercased(), + ].compactMap { $0 }.joined(separator: "+") + } +} + +public let headlessMenuShortcuts: [MenuShortcutSpec] = [ + .init( + menu: "Headless", title: "Hide Headless", key: "h", selector: "hide:", + target: .application + ), + .init( + menu: "Headless", title: "Hide Others", key: "h", option: true, + selector: "hideOtherApplications:", target: .application + ), + .init( + menu: "Headless", title: "Quit Headless", key: "q", selector: "terminate:", + target: .application + ), + .init( + menu: "File", title: "New Window", key: "n", selector: "newWindow:", + target: .appDelegate + ), + .init(menu: "File", title: "Open Location…", key: "l", selector: "openLocation:"), + .init( + menu: "File", title: "Save Snapshot to Desktop", key: "s", shift: true, + selector: "saveSnapshot:", separatorBefore: true + ), + .init( + menu: "File", title: "Close Window", key: "w", selector: "performClose:", + separatorBefore: true + ), + .init(menu: "Edit", title: "Undo", key: "z", selector: "undo:"), + .init(menu: "Edit", title: "Redo", key: "z", shift: true, selector: "redo:"), + .init(menu: "Edit", title: "Cut", key: "x", selector: "cut:", separatorBefore: true), + .init(menu: "Edit", title: "Copy", key: "c", selector: "copy:"), + .init(menu: "Edit", title: "Paste", key: "v", selector: "paste:"), + .init(menu: "Edit", title: "Select All", key: "a", selector: "selectAll:"), + .init( + menu: "Edit", title: "Copy Current URL", key: "c", shift: true, + selector: "copyPageURL:", separatorBefore: true + ), + .init(menu: "View", title: "Reload Page", key: "r", selector: "reloadPage:"), + .init( + menu: "View", title: "Reload Ignoring Cache", key: "r", shift: true, + selector: "hardReloadPage:" + ), + .init( + menu: "View", title: "Zoom In", key: "=", selector: "zoomInPage:", + separatorBefore: true + ), + .init(menu: "View", title: "Zoom Out", key: "-", selector: "zoomOutPage:"), + .init(menu: "View", title: "Actual Size", key: "0", selector: "resetZoom:"), + .init( + menu: "View", title: "Enter Full Screen", key: "f", control: true, + selector: "toggleFullScreen:", separatorBefore: true + ), + .init(menu: "History", title: "Back", key: "[", selector: "goBackAction:"), + .init(menu: "History", title: "Forward", key: "]", selector: "goForwardAction:"), + .init(menu: "Window", title: "Minimize", key: "m", selector: "performMiniaturize:"), + .init( + menu: "Window", title: "Pin on Top", key: "p", option: true, + selector: "togglePin:", separatorBefore: true + ), + .init( + menu: "Help", title: "Headless Help", key: "/", shift: true, + selector: "showHelpPage:" + ), +] diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index fe1f373..ac5ab57 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -3095,6 +3095,91 @@ struct ProtocolTests { try expect(checked >= 30, "expected to check every command line, checked \(checked)") } + static func menuShortcutsHaveUniqueChords() throws { + var seen: [String: String] = [:] + var titles: [String: String] = [:] + for spec in headlessMenuShortcuts { + try expect(!spec.selector.isEmpty, "\(spec.title) is missing a selector") + try expect(!spec.key.isEmpty, "\(spec.title) should not be in the keyed catalog without a chord") + try expect(spec.selector.hasSuffix(":"), "\(spec.title) selector must be an ObjC action") + if let previous = seen[spec.chordIdentity] { + throw TestFailure( + description: "\(spec.title) collides with \(previous) on \(spec.chordIdentity)" + ) + } + seen[spec.chordIdentity] = spec.title + if let previous = titles[spec.title] { + throw TestFailure(description: "duplicate menu title \(spec.title) also used by \(previous)") + } + titles[spec.title] = spec.selector + } + let pin = headlessMenuShortcuts.first { $0.title == "Pin on Top" } + try expect(pin?.key == "p" && pin?.command == true && pin?.option == true && pin?.shift == false, + "Pin on Top should be Cmd-Option-P, not Cmd-P") + try expect( + !headlessMenuShortcuts.contains { $0.key == "," }, + "Cmd-, is reserved for a future Settings window" + ) + let snapshot = headlessMenuShortcuts.first { $0.title == "Save Snapshot to Desktop" } + try expect(snapshot?.key == "s" && snapshot?.shift == true, + "snapshot capture should stay Cmd-Shift-S") + let help = headlessMenuShortcuts.first { $0.title == "Headless Help" } + try expect(help?.key == "/" && help?.shift == true, + "Headless Help should be Cmd-Shift-/") + try expect( + pin?.selector == "togglePin:" && pin?.target == .firstResponder, + "Pin on Top must keep togglePin: on the first responder" + ) + try expect( + help?.selector == "showHelpPage:" && help?.target == .firstResponder, + "Headless Help must keep showHelpPage: on the first responder" + ) + let textEditingActions = [ + "Undo": "undo:", "Redo": "redo:", "Cut": "cut:", "Copy": "copy:", + "Paste": "paste:", "Select All": "selectAll:", + ] + for (title, selector) in textEditingActions { + let shortcut = headlessMenuShortcuts.first { $0.title == title } + try expect( + shortcut?.selector == selector && shortcut?.target == .firstResponder, + "\(title) must target the active text responder" + ) + } + let fullScreen = headlessMenuShortcuts.first { $0.title == "Enter Full Screen" } + try expect( + fullScreen?.selector == "toggleFullScreen:" && fullScreen?.target == .firstResponder, + "full screen must retain the standard responder-chain action" + ) + let newWindow = headlessMenuShortcuts.first { $0.title == "New Window" } + try expect( + newWindow?.selector == "newWindow:" && newWindow?.target == .appDelegate, + "New Window must target the app delegate" + ) + let quit = headlessMenuShortcuts.first { $0.title == "Quit Headless" } + try expect( + quit?.selector == "terminate:" && quit?.target == .application, + "Quit Headless must target NSApp" + ) + let p0 = try String(contentsOfFile: "docs/P0.md", encoding: .utf8) + try expect( + p0.contains("Cmd-Option-P") && p0.contains("Cmd-Shift-S"), + "P0 should document the Pin and snapshot chords" + ) + let host = try String(contentsOfFile: "main.swift", encoding: .utf8) + try expect( + host.contains("⌥⌘ P"), + "start page should advertise Option-Command-P for pin" + ) + try expect( + !host.contains("⌘ P"), + "start page must not advertise Command-P for pin" + ) + try expect( + host.contains("NSSelectorFromString(spec.selector)"), + "menu items must take their actions from the catalog" + ) + } + static func authenticationProtocolAndChallengeLifecycle() throws { let login = try CLIParser().parse([ "--session", "work", "auth", "login", "--challenge", @@ -3765,6 +3850,7 @@ struct ProtocolTests { ("ephemeral authentication broker lifecycle", ephemeralAuthenticationBrokerLifecycle), ("host authentication orchestration", hostAuthenticationOrchestration), ("docs command reference matches help", docsCommandReferenceMatchesHelp), + ("menu shortcuts have unique chords", menuShortcutsHaveUniqueChords), ("artifact file upload boundaries", artifactUploadCommands), ] diff --git a/apps/headless/Tests/fixture-server.mjs b/apps/headless/Tests/fixture-server.mjs index 469457c..99bd3e5 100644 --- a/apps/headless/Tests/fixture-server.mjs +++ b/apps/headless/Tests/fixture-server.mjs @@ -17,9 +17,28 @@ const routes = new Map([ ['/allowlist-redirect', 'allowlist-redirect.html'], ['/allowlist-redirect/', 'allowlist-redirect.html'], ]); +const requestCounts = new Map(); const server = createServer(async (request, response) => { - const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname; + const requestURL = new URL(request.url ?? '/', 'http://127.0.0.1'); + const pathname = requestURL.pathname; + if (pathname === '/request-count') { + const target = requestURL.searchParams.get('path'); + if (!target?.startsWith('/')) { + response.writeHead(400, {'content-type': 'text/plain; charset=utf-8'}); + response.end('A rooted path is required'); + return; + } + const body = Buffer.from(String(requestCounts.get(target) ?? 0)); + response.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + 'content-length': body.length, + 'cache-control': 'no-store', + }); + response.end(body); + return; + } + requestCounts.set(pathname, (requestCounts.get(pathname) ?? 0) + 1); if (pathname === '/api/diagnostic') { const body = Buffer.from(JSON.stringify({ok: true})); response.writeHead(200, { diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index 5675725..6d01d40 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -2,7 +2,7 @@ set -euo pipefail cd "${0:a:h}/.." -for tool in node curl defaults lsof osascript; do +for tool in node curl defaults lsof osascript perl; do command -v "$tool" >/dev/null 2>&1 || { echo "macOS E2E tests require $tool" >&2; exit 69; } done @@ -22,9 +22,15 @@ export HEADLESS_E2E_DATA_STORE_ID="$(uuidgen)" LOG="$(mktemp "${TMPDIR:-/tmp}/headless-macos-e2e.XXXXXX")" HOST_LOG="$(mktemp "${TMPDIR:-/tmp}/headless-macos-host.XXXXXX")" RESTORE_LOG="$(mktemp "${TMPDIR:-/tmp}/headless-macos-restore.XXXXXX")" +MENU_SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/headless-macos-snapshot.XXXXXX")" +MENU_SNAPSHOT_PATH="$MENU_SNAPSHOT_DIR/menu-snapshot.png" +CLIPBOARD_BACKUP="$(mktemp "${TMPDIR:-/tmp}/headless-macos-clipboard.XXXXXX")" +CLIPBOARD_SAVED=0 +export HEADLESS_E2E_MENU_SNAPSHOT_PATH="$MENU_SNAPSHOT_PATH" export HEADLESS_HOST_LOG="$HOST_LOG" STEP="boot" RESTORE_PID="" +HOST_PID="" DEFAULTS_DOMAIN="com.headless.app" PRESENTATION_KEY="AgentStartupPresentation" DEFAULTS_HAD_LAST_URL=0 @@ -63,6 +69,8 @@ fail() { cat "$HOST_LOG" >&2 || true print -r -u2 -- "---- restore log ----" cat "$RESTORE_LOG" >&2 || true + cleanup + trap - EXIT INT TERM exit 1 } trap 'fail' ERR @@ -72,22 +80,377 @@ frontmost_pid() { -e 'ObjC.import("AppKit"); Number($.NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier)' } +activate_pid() { + local pid="$1" + osascript_with_timeout - "$pid" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Previous frontmost process was not found" + tell item 1 of targetProcesses to set frontmost to true + end tell +end run +APPLESCRIPT +} + +osascript_with_timeout() { + perl -e ' + my $seconds = shift @ARGV; + my $pid = fork(); + die "could not start osascript: $!\n" unless defined $pid; + if ($pid == 0) { + exec @ARGV; + die "could not execute osascript: $!\n"; + } + $SIG{ALRM} = sub { + kill "TERM", $pid; + select undef, undef, undef, 0.2; + kill "KILL", $pid; + waitpid $pid, 0; + print STDERR "osascript timed out; grant Accessibility access to the test runner\n"; + exit 124; + }; + alarm $seconds; + waitpid $pid, 0; + alarm 0; + my $status = $?; + exit 127 if $status == -1; + exit 128 + ($status & 127) if $status & 127; + exit $status >> 8; + ' 10 /usr/bin/osascript "$@" +} + +ax_menu_attribute() { + local pid="$1" menu_title="$2" item_title="$3" attribute_name="$4" + osascript_with_timeout - "$pid" "$menu_title" "$item_title" "$attribute_name" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + set menuTitle to item 2 of argv + set itemTitle to item 3 of argv + set attributeName to item 4 of argv + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses + set targetItem to menu item itemTitle of menu 1 of menu bar item menuTitle of menu bar 1 + set attributeValue to value of attribute attributeName of targetItem + end tell + end tell + if attributeValue is missing value then return "__MISSING__" + return attributeValue as text +end run +APPLESCRIPT +} + +ax_menu_exists() { + local pid="$1" menu_title="$2" item_title="$3" + osascript_with_timeout - "$pid" "$menu_title" "$item_title" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + set menuTitle to item 2 of argv + set itemTitle to item 3 of argv + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses + return exists menu item itemTitle of menu 1 of menu bar item menuTitle of menu bar 1 + end tell + end tell +end run +APPLESCRIPT +} + +ax_press_menu_item() { + local pid="$1" menu_title="$2" item_title="$3" + osascript_with_timeout - "$pid" "$menu_title" "$item_title" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + set menuTitle to item 2 of argv + set itemTitle to item 3 of argv + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses + set frontmost to true + perform action "AXPress" of menu item itemTitle of menu 1 of menu bar item menuTitle of menu bar 1 + end tell + end tell +end run +APPLESCRIPT +} + +ax_window_count() { + local pid="$1" + osascript_with_timeout - "$pid" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses to return count of windows + end tell +end run +APPLESCRIPT +} + +ax_focused_element() { + local pid="$1" + osascript_with_timeout - "$pid" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses + set focusedElement to value of attribute "AXFocusedUIElement" + set focusedRole to value of attribute "AXRole" of focusedElement + set focusedValue to value of attribute "AXValue" of focusedElement + end tell + end tell + if focusedValue is missing value then set focusedValue to "" + return focusedRole & tab & focusedValue +end run +APPLESCRIPT +} + +ax_escape() { + local pid="$1" + osascript_with_timeout - "$pid" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses to set frontmost to true + key code 53 + end tell +end run +APPLESCRIPT +} + +ax_keystroke() { + local pid="$1" key_text="$2" modifiers="$3" + osascript_with_timeout - "$pid" "$key_text" "$modifiers" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + set keyText to item 2 of argv + set modifierNames to item 3 of argv + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses to set frontmost to true + if modifierNames is "none" then + keystroke keyText + else if modifierNames is "command" then + keystroke keyText using {command down} + else if modifierNames is "command-shift" then + keystroke keyText using {command down, shift down} + else + error "Unsupported test modifier set" + end if + end tell +end run +APPLESCRIPT +} + +ax_front_window_attribute() { + local pid="$1" attribute_name="$2" + osascript_with_timeout - "$pid" "$attribute_name" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + set attributeName to item 2 of argv + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses + if (count of windows) is 0 then error "Headless has no accessible windows" + return value of attribute attributeName of front window + end tell + end tell +end run +APPLESCRIPT +} + +wait_for_focused_value() { + local pid="$1" expected="$2" focused_element + for _ in {1..100}; do + if focused_element="$(ax_focused_element "$pid" 2>/dev/null)" && + [[ "$focused_element" == AXTextField$'\t'"$expected" ]]; then + return 0 + fi + sleep 0.05 + done + return 1 +} + +save_clipboard() { + osascript -l JavaScript - "$CLIPBOARD_BACKUP" <<'JXA' +ObjC.import('AppKit'); +ObjC.import('Foundation'); +const path = ObjC.unwrap($.NSProcessInfo.processInfo.arguments.objectAtIndex(4)); +const items = $.NSPasteboard.generalPasteboard.pasteboardItems.js.map(item => + item.types.js.map(type => [ + ObjC.unwrap(type), + ObjC.unwrap(item.dataForType(type).base64EncodedStringWithOptions(0)), + ]) +); +if (!$(JSON.stringify(items)).writeToFileAtomicallyEncodingError( + path, true, $.NSUTF8StringEncoding, null +)) throw new Error('Could not save the pasteboard'); +JXA + CLIPBOARD_SAVED=1 +} + +restore_clipboard() { + [[ "$CLIPBOARD_SAVED" == 1 ]] || return 0 + osascript -l JavaScript - "$CLIPBOARD_BACKUP" <<'JXA' +ObjC.import('AppKit'); +ObjC.import('Foundation'); +const path = ObjC.unwrap($.NSProcessInfo.processInfo.arguments.objectAtIndex(4)); +const source = ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError( + path, $.NSUTF8StringEncoding, null +)); +const restored = $.NSMutableArray.array; +for (const representations of JSON.parse(source)) { + const item = $.NSPasteboardItem.alloc.init; + for (const [type, base64] of representations) { + const data = $.NSData.alloc.initWithBase64EncodedStringOptions($(base64), 0); + if (!item.setDataForType(data, $(type))) throw new Error(`Could not restore ${type}`); + } + restored.addObject(item); +} +const pasteboard = $.NSPasteboard.generalPasteboard; +pasteboard.clearContents; +if (restored.count > 0 && !pasteboard.writeObjects(restored)) { + throw new Error('Could not restore the pasteboard'); +} +JXA + CLIPBOARD_SAVED=0 +} + +fixture_request_count() { + local request_path="$1" count + count="$(curl -fsS --get --data-urlencode "path=$request_path" \ + "http://127.0.0.1:$PORT/request-count")" + [[ "$count" == <-> ]] || return 1 + print -r -- "$count" +} + +assert_menu_shortcut() { + local pid="$1" menu_title="$2" item_title="$3" expected_key="$4" expected_modifiers="$5" + local actual_key actual_modifiers + if ! actual_key="$(ax_menu_attribute "$pid" "$menu_title" "$item_title" AXMenuItemCmdChar)"; then + echo "Could not inspect $menu_title > $item_title through Accessibility" >&2 + return 1 + fi + if ! actual_modifiers="$(ax_menu_attribute "$pid" "$menu_title" "$item_title" AXMenuItemCmdModifiers)"; then + echo "Could not inspect $menu_title > $item_title modifiers through Accessibility" >&2 + return 1 + fi + if [[ "$actual_key" == "__MISSING__" || "${actual_key:l}" != "${expected_key:l}" ]]; then + echo "$menu_title > $item_title key was $actual_key, expected $expected_key" >&2 + return 1 + fi + if [[ "$actual_modifiers" == "__MISSING__" || "$actual_modifiers" != "$expected_modifiers" ]]; then + echo "$menu_title > $item_title modifiers were $actual_modifiers, expected $expected_modifiers" >&2 + return 1 + fi +} + +assert_system_full_screen_shortcut() { + local pid="$1" actual_key actual_modifiers + actual_key="$(ax_menu_attribute "$pid" View "Enter Full Screen" AXMenuItemCmdChar)" + actual_modifiers="$(ax_menu_attribute "$pid" View "Enter Full Screen" AXMenuItemCmdModifiers)" + if [[ "${actual_key:l}" != f ]]; then + echo "View > Enter Full Screen key was $actual_key, expected F" >&2 + return 1 + fi + # AppKit exposes Control-Command-F as 4 on older releases and the + # system-managed Function-F equivalent as 24 on newer releases. + if [[ "$actual_modifiers" != 4 && "$actual_modifiers" != 24 ]]; then + echo "View > Enter Full Screen modifiers were $actual_modifiers, expected 4 or 24" >&2 + return 1 + fi +} + +wait_for_menu_enabled() { + local pid="$1" menu_title="$2" item_title="$3" enabled + for _ in {1..100}; do + if enabled="$(ax_menu_attribute "$pid" "$menu_title" "$item_title" AXEnabled 2>/dev/null)" && + [[ "$enabled" == true ]]; then + return 0 + fi + sleep 0.05 + done + return 1 +} + node Tests/fixture-server.mjs >"$LOG" 2>&1 & FIXTURE_PID=$! cleanup() { + trap - ERR + local host_pid_output + local -a host_pids + host_pid_output="$({ + lsof -t "$HEADLESS_SOCKET" 2>/dev/null || true + if [[ -n "$HOST_PID" ]]; then print -r -- "$HOST_PID"; fi + if [[ -n "$RESTORE_PID" ]]; then print -r -- "$RESTORE_PID"; fi + true + } | sort -u)" + host_pids=("${(@f)host_pid_output}") if "$CLI" status >/dev/null 2>&1; then "$CLI" profile clear >/dev/null 2>&1 || true fi "$CLI" stop >/dev/null 2>&1 || true + for _ in {1..100}; do + local hosts_stopped=1 + for host_pid in "${host_pids[@]}"; do + if [[ -n "$host_pid" ]] && kill -0 "$host_pid" >/dev/null 2>&1; then + hosts_stopped=0 + break + fi + done + [[ "$hosts_stopped" == 1 ]] && break + sleep 0.05 + done + for host_pid in "${host_pids[@]}"; do + if [[ -n "$host_pid" ]] && kill -0 "$host_pid" >/dev/null 2>&1; then + kill "$host_pid" >/dev/null 2>&1 || true + fi + done + for _ in {1..40}; do + local hosts_stopped=1 + for host_pid in "${host_pids[@]}"; do + if [[ -n "$host_pid" ]] && kill -0 "$host_pid" >/dev/null 2>&1; then + hosts_stopped=0 + break + fi + done + [[ "$hosts_stopped" == 1 ]] && break + sleep 0.05 + done + for host_pid in "${host_pids[@]}"; do + if [[ -n "$host_pid" ]] && kill -0 "$host_pid" >/dev/null 2>&1; then + kill -9 "$host_pid" >/dev/null 2>&1 || true + fi + done if [[ -n "$RESTORE_PID" ]]; then kill "$RESTORE_PID" >/dev/null 2>&1 || true fi kill "$FIXTURE_PID" >/dev/null 2>&1 || true restore_last_url restore_startup_presentation + if ! restore_clipboard; then + print -r -u2 -- "Could not restore the pasteboard; backup retained at $CLIPBOARD_BACKUP" + fi rm -rf "$HEADLESS_ARTIFACT_DIR" - rm -f "$LOG" "$HOST_LOG" "$RESTORE_LOG" + rm -rf "$MENU_SNAPSHOT_DIR" + rm -f "$HEADLESS_SOCKET" "$LOG" "$HOST_LOG" "$RESTORE_LOG" + if [[ "$CLIPBOARD_SAVED" == 0 ]]; then + rm -f "$CLIPBOARD_BACKUP" + fi } trap cleanup EXIT INT TERM @@ -208,6 +571,255 @@ if lsof -nP -a -p "$HOST_PID" -iTCP -sTCP:LISTEN 2>/dev/null | grep -q LISTEN; t echo "Headless host opened an unexpected TCP listener" >&2 fail fi +BACKGROUND_FRONTMOST_PID="$(frontmost_pid)" +STEP="menu-shortcut-inventory" +# AX encodes Shift, Option, and Control as bits 1, 2, and 4. Command is +# implicit unless the NoCommand bit (8) is present. +while IFS=$'\t' read -r menu_title item_title expected_key expected_modifiers; do + assert_menu_shortcut "$HOST_PID" "$menu_title" "$item_title" "$expected_key" "$expected_modifiers" +done <<'SHORTCUTS' +Headless Hide Headless h 0 +Headless Hide Others h 2 +Headless Quit Headless q 0 +File New Window n 0 +File Open Location… l 0 +File Save Snapshot to Desktop s 1 +File Close Window w 0 +Edit Undo z 0 +Edit Redo z 1 +Edit Cut x 0 +Edit Copy c 0 +Edit Paste v 0 +Edit Select All a 0 +Edit Copy Current URL c 1 +View Reload Page r 0 +View Reload Ignoring Cache r 1 +View Zoom In = 0 +View Zoom Out - 0 +View Actual Size 0 0 +History Back [ 0 +History Forward ] 0 +Window Minimize m 0 +Window Pin on Top p 2 +Help Headless Help / 1 +SHORTCUTS +assert_system_full_screen_shortcut "$HOST_PID" +for settings_title in "Settings" "Settings…" "Preferences" "Preferences…"; do + if [[ "$(ax_menu_exists "$HOST_PID" Headless "$settings_title")" == true ]]; then + echo "$settings_title is shipped but has no Cmd-, coverage" >&2 + fail + fi +done + +STEP="menu-new-window-close" +WINDOWS_BEFORE="$(ax_window_count "$HOST_PID")" +ax_press_menu_item "$HOST_PID" File "New Window" +NEW_WINDOW_OPENED=0 +for _ in {1..100}; do + if [[ "$(ax_window_count "$HOST_PID")" == $((WINDOWS_BEFORE + 1)) ]]; then + NEW_WINDOW_OPENED=1 + break + fi + sleep 0.05 +done +if [[ "$NEW_WINDOW_OPENED" != 1 ]]; then + echo "New Window menu action did not create a window" >&2 + fail +fi +ax_press_menu_item "$HOST_PID" File "Close Window" +NEW_WINDOW_CLOSED=0 +for _ in {1..100}; do + if [[ "$(ax_window_count "$HOST_PID")" == "$WINDOWS_BEFORE" ]]; then + NEW_WINDOW_CLOSED=1 + break + fi + sleep 0.05 +done +if [[ "$NEW_WINDOW_CLOSED" != 1 ]]; then + echo "Close Window menu action did not close the active window" >&2 + fail +fi + +STEP="menu-open-location" +ax_press_menu_item "$HOST_PID" File "Open Location…" +LOCATION_VALUE="" +for _ in {1..100}; do + if FOCUSED_ELEMENT="$(ax_focused_element "$HOST_PID" 2>/dev/null)" && + [[ "$FOCUSED_ELEMENT" == AXTextField$'\t'http://* ]]; then + LOCATION_VALUE="${FOCUSED_ELEMENT#*$'\t'}" + break + fi + sleep 0.05 +done +if [[ -z "$LOCATION_VALUE" ]]; then + echo "Open Location did not focus the address field with the current URL" >&2 + fail +fi + +STEP="menu-text-responder" +save_clipboard +TEXT_SENTINEL="headlessmenualpha" +ax_keystroke "$HOST_PID" a command +ax_keystroke "$HOST_PID" "$TEXT_SENTINEL" none +wait_for_focused_value "$HOST_PID" "$TEXT_SENTINEL" +ax_keystroke "$HOST_PID" a command +ax_keystroke "$HOST_PID" c command +ax_keystroke "$HOST_PID" temporary none +wait_for_focused_value "$HOST_PID" temporary +ax_keystroke "$HOST_PID" a command +ax_keystroke "$HOST_PID" v command +wait_for_focused_value "$HOST_PID" "$TEXT_SENTINEL" +ax_keystroke "$HOST_PID" a command +ax_keystroke "$HOST_PID" x command +wait_for_focused_value "$HOST_PID" "" +ax_keystroke "$HOST_PID" v command +wait_for_focused_value "$HOST_PID" "$TEXT_SENTINEL" +ax_keystroke "$HOST_PID" z command +wait_for_focused_value "$HOST_PID" "" +ax_keystroke "$HOST_PID" z command-shift +wait_for_focused_value "$HOST_PID" "$TEXT_SENTINEL" +ax_escape "$HOST_PID" + +STEP="menu-reload" +"$CLI" visit "http://127.0.0.1:$PORT/designers/dashboard" | grep -q 'Designers Dashboard' +RELOAD_COUNT_BEFORE="$(fixture_request_count /designers/dashboard)" +ax_press_menu_item "$HOST_PID" View "Reload Page" +RELOAD_OBSERVED=0 +for _ in {1..200}; do + if RELOAD_COUNT_AFTER="$(fixture_request_count /designers/dashboard 2>/dev/null)" && + [[ "$RELOAD_COUNT_AFTER" -gt "$RELOAD_COUNT_BEFORE" ]]; then + RELOAD_OBSERVED=1 + break + fi + sleep 0.05 +done +if [[ "$RELOAD_OBSERVED" != 1 ]]; then + echo "Reload Page did not produce a new page request" >&2 + fail +fi +"$CLI" wait --text 'Designers dashboard' --settled --timeout 10000 | grep -q 'Designers Dashboard' + +STEP="menu-snapshot" +ax_press_menu_item "$HOST_PID" File "Save Snapshot to Desktop" +for _ in {1..200}; do + [[ -s "$MENU_SNAPSHOT_PATH" ]] && break + sleep 0.05 +done +if [[ -z "$MENU_SNAPSHOT_PATH" || ! -s "$MENU_SNAPSHOT_PATH" ]]; then + echo "Save Snapshot to Desktop did not create a non-empty PNG" >&2 + fail +fi +file "$MENU_SNAPSHOT_PATH" | grep -q 'PNG image data' + +STEP="menu-zoom" +"$CLI" screenshot --output menu-zoom-baseline.png >/dev/null +ax_press_menu_item "$HOST_PID" View "Zoom In" +"$CLI" screenshot --output menu-zoom-in.png >/dev/null +if cmp -s "$HEADLESS_ARTIFACT_DIR/menu-zoom-baseline.png" "$HEADLESS_ARTIFACT_DIR/menu-zoom-in.png"; then + echo "Zoom In did not change rendered page output" >&2 + fail +fi +ax_press_menu_item "$HOST_PID" View "Actual Size" +"$CLI" screenshot --output menu-zoom-reset.png >/dev/null +cmp -s "$HEADLESS_ARTIFACT_DIR/menu-zoom-baseline.png" "$HEADLESS_ARTIFACT_DIR/menu-zoom-reset.png" +ax_press_menu_item "$HOST_PID" View "Zoom Out" +"$CLI" screenshot --output menu-zoom-out.png >/dev/null +if cmp -s "$HEADLESS_ARTIFACT_DIR/menu-zoom-baseline.png" "$HEADLESS_ARTIFACT_DIR/menu-zoom-out.png"; then + echo "Zoom Out did not change rendered page output" >&2 + fail +fi +ax_press_menu_item "$HOST_PID" View "Actual Size" + +STEP="menu-full-screen" +ax_press_menu_item "$HOST_PID" View "Enter Full Screen" +FULL_SCREEN_ENTERED=0 +for _ in {1..400}; do + if FULL_SCREEN_STATE="$(ax_front_window_attribute "$HOST_PID" AXFullScreen 2>/dev/null)" && + [[ "$FULL_SCREEN_STATE" == true ]]; then + FULL_SCREEN_ENTERED=1 + break + fi + sleep 0.05 +done +if [[ "$FULL_SCREEN_ENTERED" != 1 ]]; then + echo "Enter Full Screen did not put the front window into full-screen mode" >&2 + fail +fi +EXIT_FULL_SCREEN_AVAILABLE=0 +for _ in {1..100}; do + if [[ "$(ax_menu_exists "$HOST_PID" View "Exit Full Screen" 2>/dev/null || true)" == true ]]; then + EXIT_FULL_SCREEN_AVAILABLE=1 + break + fi + sleep 0.05 +done +if [[ "$EXIT_FULL_SCREEN_AVAILABLE" != 1 ]]; then + echo "Enter Full Screen did not expose the standard Exit Full Screen action" >&2 + fail +fi +ax_press_menu_item "$HOST_PID" View "Exit Full Screen" +FULL_SCREEN_EXITED=0 +for _ in {1..400}; do + if FULL_SCREEN_STATE="$(ax_front_window_attribute "$HOST_PID" AXFullScreen 2>/dev/null)" && + [[ "$FULL_SCREEN_STATE" == false ]]; then + FULL_SCREEN_EXITED=1 + break + fi + sleep 0.05 +done +if [[ "$FULL_SCREEN_EXITED" != 1 ]]; then + echo "Exit Full Screen did not restore the front window" >&2 + fail +fi + +STEP="menu-history" +"$CLI" visit "http://127.0.0.1:$PORT/next" | grep -q 'Designer Details' +if ! wait_for_menu_enabled "$HOST_PID" History Back; then + echo "History > Back did not become enabled after visiting the next page" >&2 + fail +fi +ax_press_menu_item "$HOST_PID" History Back +"$CLI" wait --url /designers/dashboard --text 'Designers dashboard' --settled --timeout 10000 | grep -q 'Designers Dashboard' +if ! wait_for_menu_enabled "$HOST_PID" History Forward; then + echo "History > Forward did not become enabled after navigating back" >&2 + fail +fi +ax_press_menu_item "$HOST_PID" History Forward +if ! HISTORY_FORWARD_RESULT="$("$CLI" wait --url /next --text 'Designer details' --settled --timeout 10000)" || + ! grep -q 'Designer Details' <<<"$HISTORY_FORWARD_RESULT"; then + echo "History > Forward did not return to the next page" >&2 + print -r -u2 -- "$HISTORY_FORWARD_RESULT" + fail +fi + +STEP="menu-pin" +ax_press_menu_item "$HOST_PID" Window "Pin on Top" +PINNED_MARK="$(ax_menu_attribute "$HOST_PID" Window "Pin on Top" AXMenuItemMarkChar)" +if [[ "$PINNED_MARK" == "__MISSING__" || -z "$PINNED_MARK" ]]; then + echo "Pin on Top did not enter its checked state" >&2 + fail +fi +ax_press_menu_item "$HOST_PID" Window "Pin on Top" +UNPINNED_MARK="$(ax_menu_attribute "$HOST_PID" Window "Pin on Top" AXMenuItemMarkChar)" +if [[ "$UNPINNED_MARK" != "__MISSING__" && -n "$UNPINNED_MARK" ]]; then + echo "Pin on Top did not return to its unchecked state" >&2 + fail +fi +activate_pid "$BACKGROUND_FRONTMOST_PID" +BACKGROUND_FOCUS_RESTORED=0 +for _ in {1..100}; do + if [[ "$(frontmost_pid)" == "$BACKGROUND_FRONTMOST_PID" ]]; then + BACKGROUND_FOCUS_RESTORED=1 + break + fi + sleep 0.05 +done +if [[ "$BACKGROUND_FOCUS_RESTORED" != 1 ]]; then + echo "menu checks did not restore the previously frontmost process" >&2 + fail +fi +echo "▸ menu shortcut inventory and actions passed" + STEP="cross-engine-conformance" HEADLESS_CONFORMANCE_CLI="$CLI" \ HEADLESS_CONFORMANCE_ENGINE=webkit \ diff --git a/apps/headless/docs/P0.md b/apps/headless/docs/P0.md index 9da65e0..0656c2a 100644 --- a/apps/headless/docs/P0.md +++ b/apps/headless/docs/P0.md @@ -50,6 +50,12 @@ private permissions, no-follow file operations, locking, and atomic durable writes. Security boundaries below are fixed policy and cannot be changed with configuration. +## macOS app shortcuts + +Menu chords live in `MenuShortcuts.swift` and are tested for uniqueness. +Pin on Top is Cmd-Option-P so it does not take Cmd-P (Print). Snapshot is +Cmd-Shift-S. Cmd-, is reserved for a future Settings window and is not wired. + ## Security boundaries - Socket access is limited to the current operating-system user. diff --git a/apps/headless/main.swift b/apps/headless/main.swift index 710d3a8..54b39f7 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -5,7 +5,7 @@ // (the Safari engine). Made for clean screenshots and fullscreen video. // // ⌘L search / open url ⇧⌘S snapshot page → Desktop -// ⌘R reload ⌘P pin window on top +// ⌘R reload ⌥⌘P pin window on top // ⌘[ ⌘] back / forward ⌃⌘F fullscreen // ⌘= ⌘- ⌘0 zoom ⌘drag move the window // @@ -172,7 +172,7 @@ let startPageHTML = """