From 6d8cc2fd15661e13d5430049daf500ecb8ab436c Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Sat, 10 Oct 2026 09:48:37 -0500 Subject: [PATCH 1/3] fix(deps): adopt reviewed releases and invalidate stale Sparkle cache --- .github/workflows/ci.yml | 3 + Dependencies/Package.resolved | 8 +- Packages/GanchoKit/Package.resolved | 4 +- .../GanchoKitTests/GRDBEncryptionTests.swift | 24 ++++-- docs/DEPENDENCIES.md | 62 +++++++++++++- scripts/fetch-sparkle.sh | 27 ++++-- scripts/test-fetch-sparkle.sh | 82 +++++++++++++++++++ scripts/upstream-pins.env | 6 +- 8 files changed, 193 insertions(+), 23 deletions(-) create mode 100755 scripts/test-fetch-sparkle.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c1c09b6..ba4a2167 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,9 @@ jobs: - name: Dependency resolution locks run: make dependency-check + - name: Sparkle fetch cache regressions + run: bash scripts/test-fetch-sparkle.sh + - name: Website structure run: make site-check diff --git a/Dependencies/Package.resolved b/Dependencies/Package.resolved index 5a744f35..5db7b7c9 100644 --- a/Dependencies/Package.resolved +++ b/Dependencies/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/KeyboardShortcuts", "state" : { - "revision" : "49c3fc04ea827f816df67843bfcc57286b47ff06", - "version" : "3.0.1" + "revision" : "772133d9dbe800fdac0473226822994c5c162c58", + "version" : "3.1.0" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sqlcipher/SQLCipher.swift.git", "state" : { - "revision" : "205df55271aa1ba512a9bfe3fd1813bc9ac52a19", - "version" : "4.17.0" + "revision" : "39f212458aeb88e33bdac2200a793a3f0d55d32b", + "version" : "4.19.0" } }, { diff --git a/Packages/GanchoKit/Package.resolved b/Packages/GanchoKit/Package.resolved index 4d8e5b3e..d3b2e23f 100644 --- a/Packages/GanchoKit/Package.resolved +++ b/Packages/GanchoKit/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sqlcipher/SQLCipher.swift.git", "state" : { - "revision" : "205df55271aa1ba512a9bfe3fd1813bc9ac52a19", - "version" : "4.17.0" + "revision" : "39f212458aeb88e33bdac2200a793a3f0d55d32b", + "version" : "4.19.0" } }, { diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift index eb1af36e..a2f19006 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift @@ -223,10 +223,14 @@ import Testing "a non-fresh failure must leave the store untouched") } - @Test("A pre-encryption plaintext store migrates in place without losing clips") - func migratesPlaintextStore() async throws { - let dir = tempDir() - defer { try? FileManager.default.removeItem(at: dir) } + @Test( + "A pre-encryption plaintext store migrates in place without losing clips", + arguments: [false, true]) + func migratesPlaintextStore(quotedDirectory: Bool) async throws { + let root = tempDir() + let dir = root.appendingPathComponent( + quotedDirectory ? "Owner's archive" : "archive", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } // 1. Seed a plaintext store (passphrase nil ⇒ no encryption). var plaintext: GRDBClipboardStore? = try GRDBClipboardStore( @@ -245,11 +249,15 @@ import Testing // 2. Reopen WITH a key ⇒ in-place re-encryption runs. let key = try testKey() - let encrypted = try GRDBClipboardStore(directory: dir, passphrase: key) + var encrypted: GRDBClipboardStore? = try GRDBClipboardStore(directory: dir, passphrase: key) - // Clip survived. - #expect(try await encrypted.content(for: item.id) == .text(Self.needle)) - #expect(try await encrypted.count() == 1) + // Clip survived the export, including an apostrophe in its file path. + #expect(try await encrypted?.content(for: item.id) == .text(Self.needle)) + #expect(try await encrypted?.count() == 1) + encrypted = nil + let reopened = try GRDBClipboardStore(directory: dir, passphrase: key) + #expect(try await reopened.content(for: item.id) == .text(Self.needle)) + #expect(try await reopened.count() == 1) // And the file is now encrypted. let after = try databaseBytes(in: dir) diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 00f0b660..13e22a77 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -89,7 +89,7 @@ initial panel binding; changing those raw names would orphan existing user preferences. Feature controllers register handlers, while Settings and onboarding use the package's recorder UI. -Version 3.0.1 is the current baseline. Its Swift 6 implementation replaced the +Version 3.1.0 is the current baseline. The 3.0 Swift 6 implementation replaced the registration engine and renamed `Name(default:)` to `Name(initial:)` without changing Gancho's stored Carbon key-code/modifier representation. Signed UI coverage must prove that an existing shortcut restores and has an active @@ -108,3 +108,63 @@ release tarball and verifies a pinned SHA-256. Updating = new version + new checksum in that script (the canary's `SPARKLE` pin cross-checks it), then the signed direct-download DMG must build with re-signed helpers and pass `codesign --verify --deep --strict` before merging. + +## October 2026 maintenance review + +The adopted stable releases match the October 5 canary. This is a dependency +update, not a GRDB fork rebase or an application schema migration. + +- **SQLCipher.swift 4.17.0 → 4.19.0.** The existing GRDB fork requirement + (`from: "4.17.0"`) already admits this version. The annotated release tag + peels to `39f212458aeb88e33bdac2200a793a3f0d55d32b`; both canonical locks + use that commit. Its Swift 6.0 / macOS 10.13 / iOS 12 floors fit Gancho. + [Zetetic's advisory](https://www.zetetic.net/blog/2026/09/08/sqlcipher-4.19.0-release/) + describes two low-risk issues in older versions: unquoted attached schema + aliases in `sqlcipher_export` and invalid `hexkey` URI input. Gancho uses a + fixed `encrypted` alias and `usePassphrase`, not a `hexkey` URI. The reviewed + paths do not establish exploitability in Gancho; the update still brings + upstream error-handling and migration fixes. The encryption migration test + now covers an apostrophe in the directory name and reopening after export. +- **KeyboardShortcuts 3.0.1 → 3.1.0.** The existing app requirement already + admits it. The annotated tag peels to + `772133d9dbe800fdac0473226822994c5c162c58`. Swift 6.2 and macOS 10.15 remain + compatible with the supported build toolchain and deployment floor. + [The upstream delta](https://github.com/sindresorhus/KeyboardShortcuts/compare/3.0.1...3.1.0) + changes menu tracking, synthesized Fn handling, and recorder pause/resume; + it does not require changing Gancho's stable names or stored key codes. +- **Sparkle 2.9.6 → 2.10.0.** + [The release](https://github.com/sparkle-project/Sparkle/releases/tag/2.10.0) + raises its macOS floor to 12, below Gancho's 15.4, and fixes update progress, + delta handling, and signed-feed diagnostics. The tarball checksum is the + official release asset's SHA-256. The fetch cache now records both version + and checksum; an old unmarked framework can no longer silently survive a + pin update. Offline fixture tests cover reuse, invalidation, missing tools, + forced downloads, and preservation after failed verification. + +### Verification and merge gates + +The lock revisions above were checked against the upstream annotated tags and +immutable commit manifests; they were not regenerated locally with Xcode. +Manifest requirements and origin hashes are unchanged. CI must consume both +locks with automatic resolution disabled and pass dependency coherence, +formatting, lint, package encryption/raw-key/migration/durability tests, +StoreKit tests, and both app builds. A local fixture test is not evidence that +the real Sparkle binary was downloaded or signed correctly. + +Before merge, also record the results that ordinary unsigned PR CI cannot +supply: + +- `GANCHO_PERF=1 make bench` for storage/search budgets. +- A signed build reopening a backed-up, existing store produced by the old + SQLCipher version, checking clips and binary payloads; fresh test databases + created and reopened with the new version do not prove cross-version upgrade. +- A signed direct-download DMG with re-signed helpers and + `codesign --verify --deep --strict`, plus the signed updater smoke check. +- Signed shortcut restoration without preference changes, re-recording the + existing shortcut, cancelling/switching recorders, conflict rejection, and + function-key activation with a menu open. Check the lowest supported macOS + and a current supported system. Package conflict tests do not exercise the + global registration or AppKit recorder lifecycle. + +Keep this work in draft until those gates have evidence. Do not close the +upstream canary issue merely because its original body mentions older releases. diff --git a/scripts/fetch-sparkle.sh b/scripts/fetch-sparkle.sh index 0e0b9fc2..3cfb91e7 100755 --- a/scripts/fetch-sparkle.sh +++ b/scripts/fetch-sparkle.sh @@ -8,19 +8,27 @@ set -euo pipefail repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" cd "$repo_root" -SPARKLE_VERSION="${SPARKLE_VERSION:-2.9.6}" -SPARKLE_SHA256="${SPARKLE_SHA256:-52bf9e88cdd972fc0c81501377a880e90d47031bd8ca5462488f843e2609e192}" +SPARKLE_VERSION="${SPARKLE_VERSION:-2.10.0}" +SPARKLE_SHA256="${SPARKLE_SHA256:-c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c}" VENDOR_DIR="${VENDOR_DIR:-Vendor}" FRAMEWORK="$VENDOR_DIR/Sparkle.framework" TOOLS="$VENDOR_DIR/bin" +RECEIPT="$VENDOR_DIR/.sparkle-receipt" +expected_receipt="$SPARKLE_VERSION $SPARKLE_SHA256" -if [ -d "$FRAMEWORK" ] && [ -x "$TOOLS/sign_update" ] && [ -z "${FORCE:-}" ]; then +# A cache from another pin (or before receipts existed) must not bypass the +# checksum-verified download. The receipt records provenance, not a signature +# check of mutable local files; release packaging still verifies code signing. +if [ -d "$FRAMEWORK" ] && [ -x "$TOOLS/sign_update" ] \ + && [ -x "$TOOLS/generate_appcast" ] && [ -x "$TOOLS/generate_keys" ] \ + && [ -f "$RECEIPT" ] && [ "$(cat "$RECEIPT")" = "$expected_receipt" ] \ + && [ -z "${FORCE:-}" ]; then printf '✓ Sparkle.framework + bin/ already present in %s/ (set FORCE=1 to refetch)\n' "$VENDOR_DIR" exit 0 fi url="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" -tmp="$(mktemp -d -t sparkle)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/sparkle.XXXXXX")" trap 'rm -rf "$tmp"' EXIT printf '==> Downloading Sparkle %s\n' "$SPARKLE_VERSION" @@ -36,9 +44,15 @@ if [ "$actual" != "$SPARKLE_SHA256" ]; then fi printf '==> Extracting Sparkle.framework into %s/\n' "$VENDOR_DIR" +tar -xf "$tmp/sparkle.tar.xz" -C "$tmp" +# Reject incomplete archives before touching the working installation. +[ -d "$tmp/Sparkle.framework" ] || { echo "error: missing Sparkle.framework" >&2; exit 1; } +for tool in sign_update generate_appcast generate_keys; do + [ -x "$tmp/bin/$tool" ] || { echo "error: missing Sparkle tool: $tool" >&2; exit 1; } +done mkdir -p "$VENDOR_DIR" +rm -f "$RECEIPT" rm -rf "$FRAMEWORK" -tar -xf "$tmp/sparkle.tar.xz" -C "$tmp" cp -R "$tmp/Sparkle.framework" "$FRAMEWORK" printf '✓ %s (Sparkle %s)\n' "$FRAMEWORK" "$SPARKLE_VERSION" @@ -51,3 +65,6 @@ if [ -d "$tmp/bin" ]; then cp -R "$tmp/bin" "$TOOLS" printf '✓ %s (sign_update, generate_appcast, generate_keys)\n' "$TOOLS" fi + +# Publish only after both the framework and tools were installed successfully. +printf '%s\n' "$expected_receipt" >"$RECEIPT" diff --git a/scripts/test-fetch-sparkle.sh b/scripts/test-fetch-sparkle.sh new file mode 100755 index 00000000..339c2c26 --- /dev/null +++ b/scripts/test-fetch-sparkle.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Offline integration tests for fetch-sparkle.sh. Only curl is replaced; the +# production script hashes, extracts, installs, and reuses real fixture archives. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/gancho-sparkle-test.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/repo/scripts" "$tmp/mock" "$tmp/archive/Sparkle.framework" "$tmp/archive/bin" +git init -q "$tmp/repo" +cp "$root/scripts/fetch-sparkle.sh" "$tmp/repo/scripts/" +for tool in sign_update generate_appcast generate_keys; do + printf '#!/bin/sh\nexit 0\n' >"$tmp/archive/bin/$tool" + chmod +x "$tmp/archive/bin/$tool" +done +printf 'fixture framework\n' >"$tmp/archive/Sparkle.framework/Sparkle" +tar -cf "$tmp/good.tar" -C "$tmp/archive" Sparkle.framework bin +export FIXTURE_ARCHIVE="$tmp/good.tar" DOWNLOAD_LOG="$tmp/downloads" +: >"$DOWNLOAD_LOG" +cat >"$tmp/mock/curl" <<'CURL' +#!/usr/bin/env bash +set -euo pipefail +printf 'download\n' >>"$DOWNLOAD_LOG" +while [ "$#" -gt 0 ]; do + if [ "$1" = -o ]; then cp "$FIXTURE_ARCHIVE" "$2"; exit 0; fi + shift +done +exit 2 +CURL +chmod +x "$tmp/mock/curl" +export PATH="$tmp/mock:$PATH" +export SPARKLE_VERSION="fixture-1" +export SPARKLE_SHA256="$(shasum -a 256 "$FIXTURE_ARCHIVE" | awk '{print $1}')" +export VENDOR_DIR="$tmp/repo/Vendor" +unset FORCE +cd "$tmp/repo" +fetch() { bash scripts/fetch-sparkle.sh >"$tmp/output" 2>&1; } +expect_downloads() { + actual="$(wc -l <"$DOWNLOAD_LOG" | tr -d ' ')" + [ "$actual" = "$1" ] || { cat "$tmp/output"; echo "expected $1 downloads, got $actual" >&2; exit 1; } +} +# An existing unmarked framework used to suppress every future version bump. +mkdir -p "$VENDOR_DIR/Sparkle.framework" "$VENDOR_DIR/bin" +printf 'old framework\n' >"$VENDOR_DIR/Sparkle.framework/Sparkle" +cp "$tmp/archive/bin/sign_update" "$VENDOR_DIR/bin/" +fetch +expect_downloads 1 +cmp "$tmp/archive/Sparkle.framework/Sparkle" "$VENDOR_DIR/Sparkle.framework/Sparkle" +fetch +expect_downloads 1 +# Either half of the pin changing invalidates the cache. +export SPARKLE_VERSION="fixture-2" +fetch +expect_downloads 2 +printf 'replacement framework\n' >"$tmp/archive/Sparkle.framework/Sparkle" +tar -cf "$tmp/updated.tar" -C "$tmp/archive" Sparkle.framework bin +export FIXTURE_ARCHIVE="$tmp/updated.tar" +export SPARKLE_SHA256="$(shasum -a 256 "$FIXTURE_ARCHIVE" | awk '{print $1}')" +fetch +expect_downloads 3 +cmp "$tmp/archive/Sparkle.framework/Sparkle" "$VENDOR_DIR/Sparkle.framework/Sparkle" +# Incomplete local tools are repaired; FORCE still deliberately refetches. +rm "$VENDOR_DIR/bin/generate_appcast" +fetch +expect_downloads 4 +FORCE=1 fetch +expect_downloads 5 +cp "$VENDOR_DIR/.sparkle-receipt" "$tmp/receipt" +# A bad checksum must preserve the usable installation and its receipt. +export SPARKLE_SHA256="invalid" +if fetch; then echo 'expected checksum rejection' >&2; exit 1; fi +expect_downloads 6 +cmp "$tmp/receipt" "$VENDOR_DIR/.sparkle-receipt" +cmp "$tmp/archive/Sparkle.framework/Sparkle" "$VENDOR_DIR/Sparkle.framework/Sparkle" +# A checksum-valid but incomplete release must also preserve the installation. +tar -cf "$tmp/incomplete.tar" -C "$tmp/archive" Sparkle.framework +export FIXTURE_ARCHIVE="$tmp/incomplete.tar" +export SPARKLE_SHA256="$(shasum -a 256 "$FIXTURE_ARCHIVE" | awk '{print $1}')" +if fetch; then echo 'expected missing-tool rejection' >&2; exit 1; fi +expect_downloads 7 +cmp "$tmp/receipt" "$VENDOR_DIR/.sparkle-receipt" +[ -x "$VENDOR_DIR/bin/generate_appcast" ] +printf 'PASS: legacy cache, matching pin, version, checksum, missing tool, FORCE, checksum failure, incomplete archive\n' diff --git a/scripts/upstream-pins.env b/scripts/upstream-pins.env index 7101a7ec..0ffc90dd 100644 --- a/scripts/upstream-pins.env +++ b/scripts/upstream-pins.env @@ -11,7 +11,7 @@ # KEYBOARDSHORTCUTS ↔ Dependencies/Package.resolved (app-wide graph) # GRDB tracks the UPSTREAM groue tag on which the exact fork revision is based. GRDB=7.11.1 -SQLCIPHER=4.17.0 -SPARKLE=2.9.6 +SQLCIPHER=4.19.0 +SPARKLE=2.10.0 SAUCE=2.5.2 -KEYBOARDSHORTCUTS=3.0.1 +KEYBOARDSHORTCUTS=3.1.0 From 8feb071ccc526e2c04169677ec5ffcc421a39161 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Sat, 10 Oct 2026 09:50:52 -0500 Subject: [PATCH 2/3] style(tests): wrap encrypted store initialization --- .../GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift index a2f19006..3006fcaa 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift @@ -249,7 +249,8 @@ import Testing // 2. Reopen WITH a key ⇒ in-place re-encryption runs. let key = try testKey() - var encrypted: GRDBClipboardStore? = try GRDBClipboardStore(directory: dir, passphrase: key) + var encrypted: GRDBClipboardStore? = try GRDBClipboardStore( + directory: dir, passphrase: key) // Clip survived the export, including an apostrophe in its file path. #expect(try await encrypted?.content(for: item.id) == .text(Self.needle)) From e26224256dad84e5fdadf7326147ae88a5581155 Mon Sep 17 00:00:00 2001 From: johnny4young Date: Tue, 13 Oct 2026 07:23:21 -0500 Subject: [PATCH 3/3] fix(deps): hold shortcuts at the validated menu version --- Dependencies/Package.resolved | 6 +- Makefile | 8 +- .../GanchoKitTests/ReleaseMetadataTests.swift | 11 + .../FixtureApp.swift | 193 +++++++ Tests/KeyboardShortcutsAcceptance/README.md | 112 ++++ .../RecorderFixtureApp.swift | 244 ++++++++ .../ShortcutMenuUITests.swift | 167 ++++++ .../ShortcutRecorderUITests.swift | 534 ++++++++++++++++++ Tests/KeyboardShortcutsAcceptance/prepare.py | 153 +++++ docs/DEPENDENCIES.md | 63 ++- project.yml | 3 +- scripts/test-keyboard-shortcuts-menu.sh | 87 +++ scripts/upstream-pins.env | 3 +- 13 files changed, 1568 insertions(+), 16 deletions(-) create mode 100644 Tests/KeyboardShortcutsAcceptance/FixtureApp.swift create mode 100644 Tests/KeyboardShortcutsAcceptance/README.md create mode 100644 Tests/KeyboardShortcutsAcceptance/RecorderFixtureApp.swift create mode 100644 Tests/KeyboardShortcutsAcceptance/ShortcutMenuUITests.swift create mode 100644 Tests/KeyboardShortcutsAcceptance/ShortcutRecorderUITests.swift create mode 100644 Tests/KeyboardShortcutsAcceptance/prepare.py create mode 100755 scripts/test-keyboard-shortcuts-menu.sh diff --git a/Dependencies/Package.resolved b/Dependencies/Package.resolved index 5db7b7c9..0e403588 100644 --- a/Dependencies/Package.resolved +++ b/Dependencies/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e07a4c5d45c6d91600fd11da0174c3d6513502f4a7eb425c7fda6473039a28d9", + "originHash" : "5f93a9be9a544a1224dbf935b14a0663bde9ae870657b67f7ce29a58fb0cb74f", "pins" : [ { "identity" : "grdb.swift", @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/KeyboardShortcuts", "state" : { - "revision" : "772133d9dbe800fdac0473226822994c5c162c58", - "version" : "3.1.0" + "revision" : "49c3fc04ea827f816df67843bfcc57286b47ff06", + "version" : "3.0.1" } }, { diff --git a/Makefile b/Makefile index 0e11ebd8..08414c2d 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ export DEVELOPER_DIR := /Applications/Xcode.app/Contents/Developer endif endif -.PHONY: help project fetch-sparkle resolve-dependencies dependency-check build build-signed build-ios install-ios test test-storekit coverage coverage-check test-ui test-ui-ios ui-evidence bench format lint swiftlint warnings-check release-check package-macos package-dmg appcast qa-release site-check hooks clean open +.PHONY: help project fetch-sparkle resolve-dependencies dependency-check build build-signed build-ios install-ios test test-storekit coverage coverage-check test-ui test-ui-ios test-shortcuts-menu test-shortcuts-recorder ui-evidence bench format lint swiftlint warnings-check release-check package-macos package-dmg appcast qa-release site-check hooks clean open help: ## List available targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " make %-12s %s\n", $$1, $$2}' @@ -84,6 +84,12 @@ install-ios: project ## Build the iOS app (Debug, team-signed) and install it on test: ## Run package unit tests (Swift Testing) swift test $(SWIFT_PACKAGE_FLAGS) --package-path $(PACKAGE) +test-shortcuts-menu: ## Verify consumed shortcut events in an isolated signed native menu fixture + bash scripts/test-keyboard-shortcuts-menu.sh + +test-shortcuts-recorder: ## Verify shortcut recording and restoration in an isolated signed native fixture + SHORTCUT_ACCEPTANCE_SCOPE=recorder bash scripts/test-keyboard-shortcuts-menu.sh + coverage: ## Run package tests with coverage and enforce the production-source floor mkdir -p build set -o pipefail; swift test $(SWIFT_PACKAGE_FLAGS) --package-path $(PACKAGE) --enable-code-coverage \ diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/ReleaseMetadataTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/ReleaseMetadataTests.swift index 122ddfd8..6cc72ca8 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/ReleaseMetadataTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/ReleaseMetadataTests.swift @@ -325,6 +325,17 @@ struct ReleaseMetadataTests { #expect(stale.status == 1, "an advanced upstream must fail the canary") #expect(stale.output.contains("UPSTREAM ADVANCED: GRDB"), Comment(rawValue: stale.output)) + let advancedShortcuts = pinLines.map { + $0.hasPrefix("KEYBOARDSHORTCUTS=") ? "KEYBOARDSHORTCUTS=99.0.0" : $0 + } + let staleShortcuts = try runCanary(latest: advancedShortcuts) + #expect( + staleShortcuts.status == 1, + "a held shortcut dependency must still report newer releases") + #expect( + staleShortcuts.output.contains("UPSTREAM ADVANCED: KEYBOARDSHORTCUTS"), + Comment(rawValue: staleShortcuts.output)) + // A pin AHEAD of the latest release (pre-release, fork tag, rollback) // is deliberate state, not drift — reported, never a failure. let behind = pinLines.map { diff --git a/Tests/KeyboardShortcutsAcceptance/FixtureApp.swift b/Tests/KeyboardShortcutsAcceptance/FixtureApp.swift new file mode 100644 index 00000000..220a7c29 --- /dev/null +++ b/Tests/KeyboardShortcutsAcceptance/FixtureApp.swift @@ -0,0 +1,193 @@ +import AppKit +import KeyboardShortcuts + +/// A disposable dependency host. It never starts Gancho's composition root. +@MainActor +final class MenuFixtureState { + var menuTracking = false + var keyDownCount = 0 + var keyUpCount = 0 + var trace: [[String: Any]] = [] + let snapshotURL: URL + + init() { + let arguments = CommandLine.arguments + let prefix = "com.johnny4young.gancho.tests.shortcut-menu." + guard let identifier = Bundle.main.bundleIdentifier, + identifier.hasPrefix(prefix), + identifier.dropFirst(prefix.count).count == 32, + identifier.dropFirst(prefix.count).allSatisfy({ "0123456789abcdef".contains($0) }), + let index = arguments.firstIndex(of: "-fixture-snapshot"), + arguments.indices.contains(index + 1) + else { fatalError("An isolated fixture identity and snapshot are required") } + snapshotURL = URL(fileURLWithPath: arguments[index + 1]).standardizedFileURL + guard snapshotURL.lastPathComponent == "snapshot.json", + snapshotURL.deletingLastPathComponent().lastPathComponent.hasPrefix( + "gancho-shortcut-menu.") + else { fatalError("Snapshot must belong to the disposable fixture directory") } + + // Reset only the fresh UUID fixture domain; never Gancho's preferences. + UserDefaults.standard.removePersistentDomain(forName: identifier) + KeyboardShortcuts.setShortcut( + .init(.v, modifiers: [.command, .control, .option, .shift]), for: .togglePanel) + KeyboardShortcuts.onKeyDown(for: .togglePanel) { [weak self] in + guard let self else { return } + keyDownCount += 1 + record("down-callback", event: NSApp.currentEvent) + } + KeyboardShortcuts.onKeyUp(for: .togglePanel) { [weak self] in + guard let self else { return } + keyUpCount += 1 + record("up-callback", event: NSApp.currentEvent) + } + writeSnapshot() + } + + func record(_ phase: String, event: NSEvent? = nil, mode: RunLoop.Mode? = nil) { + var entry: [String: Any] = ["phase": phase, "menuTracking": menuTracking] + if let event { + // Collect metadata only for the fixture chord, never key characters. + guard [.keyDown, .keyUp].contains(event.type), event.keyCode == 9, + event.modifierFlags.intersection(.deviceIndependentFlagsMask) + == [.command, .control, .option, .shift] + else { return } + entry["type"] = event.type.rawValue + entry["timestamp"] = event.timestamp + entry["isRepeat"] = event.isARepeat + entry["keyCode"] = event.keyCode + entry["modifiers"] = event.modifierFlags.rawValue + } + if let mode { entry["mode"] = mode.rawValue } + trace.append(entry) + writeSnapshot() + } + + func writeSnapshot() { + let shortcut = KeyboardShortcuts.getShortcut(for: .togglePanel) + let state: [String: Any] = [ + "bundleIdentifier": Bundle.main.bundleIdentifier ?? "missing", + "version": Bundle.main.object(forInfoDictionaryKey: "FixtureDependencyVersion") + as? String ?? "missing", + "revision": Bundle.main.object(forInfoDictionaryKey: "FixtureDependencyRevision") + as? String ?? "missing", + "shortcutEnabled": KeyboardShortcuts.isEnabled(for: .togglePanel), + "shortcutKeyCode": shortcut?.carbonKeyCode ?? -1, + "shortcutModifiers": shortcut?.carbonModifiers ?? -1, + "menuTracking": menuTracking, + "keyDownCount": keyDownCount, "keyUpCount": keyUpCount, "trace": trace + ] + do { + let data = try JSONSerialization.data( + withJSONObject: state, options: [.sortedKeys, .prettyPrinted]) + try data.write(to: snapshotURL, options: .atomic) + } catch { + fatalError("Could not save synthetic fixture event metadata: \(error)") + } + } +} + +@main +@MainActor +final class ShortcutMenuFixture: NSObject, NSApplicationDelegate, NSMenuDelegate { + private var window: NSWindow? + private var menu: NSMenu? + + static func main() { + let app = MenuFixtureApplication.shared + let delegate = ShortcutMenuFixture() + app.delegate = delegate + withExtendedLifetime(delegate) { app.run() } + } + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + let state = MenuFixtureState() + (NSApp as? MenuFixtureApplication)?.fixtureState = state + + let fixtureMenu = NSMenu(title: "Fixture Menu") + fixtureMenu.delegate = self + fixtureMenu.addItem( + withTitle: "Reserved Fixture Action", action: #selector(reservedAction), + keyEquivalent: "j") + fixtureMenu.items.last?.target = self + let boundItem = NSMenuItem( + title: "Current Panel Binding", action: #selector(reservedAction), keyEquivalent: "") + boundItem.target = self + boundItem.setShortcut(for: .togglePanel) + fixtureMenu.addItem(boundItem) + menu = fixtureMenu + + let mainMenu = NSMenu() + let menuItem = NSMenuItem(title: "Fixture Menu", action: nil, keyEquivalent: "") + menuItem.submenu = fixtureMenu + mainMenu.addItem(menuItem) + NSApp.mainMenu = mainMenu + + let fixtureWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 180), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + fixtureWindow.title = "Shortcut Menu Fixture" + let button = NSButton(title: "Open fixture menu", target: self, action: #selector(openMenu)) + button.frame = NSRect(x: 80, y: 70, width: 260, height: 36) + button.setAccessibilityIdentifier("open-menu-button") + fixtureWindow.contentView?.addSubview(button) + fixtureWindow.center() + fixtureWindow.makeKeyAndOrderFront(nil) + window = fixtureWindow + NSRunningApplication.current.activate(options: [.activateAllWindows]) + } + + @objc private func openMenu() { + guard let view = window?.contentView else { return } + menu?.popUp(positioning: nil, at: NSPoint(x: 100, y: 110), in: view) + } + + @objc private func reservedAction() {} + + func menuWillOpen(_ menu: NSMenu) { + let state = (NSApp as? MenuFixtureApplication)?.fixtureState + state?.menuTracking = true + state?.record("menu-open") + } + + func menuDidClose(_ menu: NSMenu) { + let state = (NSApp as? MenuFixtureApplication)?.fixtureState + state?.menuTracking = false + state?.record("menu-close") + } + + func applicationWillTerminate(_ notification: Notification) { + if let identifier = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: identifier) + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } +} + +/// Observe forwarding without changing the return value or event route. +@objc(MenuFixtureApplication) +@MainActor +final class MenuFixtureApplication: NSApplication { + var fixtureState: MenuFixtureState? + + override func nextEvent( + matching mask: NSEvent.EventTypeMask, until expiration: Date?, + inMode mode: RunLoop.Mode, dequeue: Bool + ) -> NSEvent? { + let event = super.nextEvent( + matching: mask, until: expiration, inMode: mode, dequeue: dequeue) + if dequeue, let event { fixtureState?.record("dequeue", event: event, mode: mode) } + return event + } + + override func postEvent(_ event: NSEvent, atStart: Bool) { + fixtureState?.record("repost", event: event) + super.postEvent(event, atStart: atStart) + } + + override func sendEvent(_ event: NSEvent) { + fixtureState?.record("send", event: event) + super.sendEvent(event) + } +} diff --git a/Tests/KeyboardShortcutsAcceptance/README.md b/Tests/KeyboardShortcutsAcceptance/README.md new file mode 100644 index 00000000..9dc0e3e3 --- /dev/null +++ b/Tests/KeyboardShortcutsAcceptance/README.md @@ -0,0 +1,112 @@ +# Native KeyboardShortcuts acceptance + +These optional macOS XCUITests build disposable, signed dependency hosts from +the **actual revision in `Dependencies/Package.resolved`**. Its version must +also match the exact requirement in `project.yml` and the upstream canary pin. +The script archives that revision from an existing local Git repository, so +the test neither fetches packages nor uses an edited dependency checkout. + +Run on an unlocked Mac with Xcode, XcodeGen, Python 3, and an existing trusted +signing identity: + +```bash +KEYBOARDSHORTCUTS_CHECKOUT=/path/to/KeyboardShortcuts \ +SHORTCUT_MENU_SIGN_IDENTITY='Developer ID Application: Your Name (TEAMID)' \ +./scripts/test-keyboard-shortcuts-menu.sh +``` + +The equivalent menu target is `make test-shortcuts-menu`. Run the recorder +gate separately with the same environment: + +```bash +KEYBOARDSHORTCUTS_CHECKOUT=/path/to/KeyboardShortcuts \ +SHORTCUT_MENU_SIGN_IDENTITY='Developer ID Application: Your Name (TEAMID)' \ +make test-shortcuts-recorder +``` + +`SHORTCUT_ACCEPTANCE_SCOPE=recorder ./scripts/test-keyboard-shortcuts-menu.sh` +selects that same recorder gate. Neither target launches Gancho or runs its +complete UI suite. Run only one native gate at a time on an unlocked desktop. + +## Menu event consumption + +The test opens a real `NSMenu` and sends one native +Command-Control-Option-Shift-V key pair. It requires exactly one down callback +and one up callback while the menu is open, both non-repeat. Transparent +`NSApplication` forwarding records only that chord's event metadata; each +callback must match one event dequeued in the tracking run-loop mode. The +consumed chord must never be reposted or forwarded to ordinary `sendEvent`, +including after the menu closes. This detects the consumed-event return-value +regression without requiring a particular passing dependency version. +Input is synthesized by XCUITest. The chord uses ANSI virtual key code 9; +the test validates matching metadata and proves behavior on the keyboard +layout actually exercised, without claiming other layouts were tested. + +The menu fixture compiles Gancho's stable shortcut-name catalog, but has no +Gancho composition root, clipboard, database, cloud, telemetry, paste writer, or +installed-app identity. It registers only its synthetic four-modifier chord +and resets only its fresh UUID preferences domain. XCUITest input is globally +synthesized: naming a target application does not guarantee delivery. Before +each click or key, the tests require the fixture to be foreground in both +XCTest and AppKit, and abort on missing windows or controls. The menu gate also +requires its synthetic chord to have an active registration before input; an +unavailable binding must fail before sending a potentially conflicting chord. +There is still a +focus race between the check and the event; keep the desktop reserved during +execution. The fixture uses no global event tap or fixture-side event posting. +Neither gate changes automation, Accessibility, or notification permissions; +report an authorization or focus failure as a blocked gate. + +## Recorder and persisted binding restoration + +The separate recorder host compiles Gancho's shortcut catalog and the +Foundation-only `ShortcutConflicts` checker unchanged, then hosts the actual SwiftUI +`KeyboardShortcuts.Recorder` in an `NSHostingView`. Inert down/up counters use the +registration APIs used by Gancho's panel and optional actions. Synthetic raw +Carbon JSON is seeded into its UUID standard-defaults domain before the first +shortcut name is accessed, preventing initial defaults from hiding a failed +restoration. All seeded chords use four modifiers and ordinary letter keys. + +Five focused tests cover: + +1. Preserved raw preferences, displayed bindings, enabled registration, and one + native down/up pair for each synthetic binding. +2. Escape cancellation, recorder switching, and a neutral outside click, with + unchanged values and resumed dispatch. +3. Re-recording the existing menu-bound binding without a self-conflict alert, + including no action dispatch from the recording gesture's final key-up. +4. Replacement, inactive old binding, single delivery of the new binding, and + a true relaunch using the same UUID domain without reset or reseeding. +5. Real fixture-menu conflict rejection, native optional-binding clearing, and + an absent stored binding with inactive registration across relaunch. + +The runner waits for both native recorders, guards each gesture, requires +actual recorder focus before recording keys, and removes only its validated +UUID domain after each complete case, including failure cleanup. The host +preserves that domain at termination so +relaunch tests exercise storage. The wrapper enforces all five tests passed, +zero failures, zero skips, zero expected failures, and a Passed result; +lack of focus must fail before input is sent. + +This gate exercises the native dependency recorder and Gancho's unchanged +shortcut-name contract on the tested runtime/layout. Menu-conflict rejection +uses the dependency's native conflict handling. The compiled Gancho conflict +checker is called for successful synthetic chords; its full reserved-system +shortcut table is not exercised by this fixture. The gate does not host the actual +Settings or onboarding views, execute Gancho's paste/capture actions, start its +composition root, or prove helper/APNs/StoreKit/Sparkle startup. Minimum macOS +15.4 and physical/function-key routes need their own runtime evidence. + +The signed macOS 26.6.2 run on exact 3.0.1 passed four recorder cases. The +unchanged-binding case remained in recording mode with zero changes/actions +and preserved preferences. The same fixture case passed on unmodified 3.1.0; +the locked 3.0.1 version also matches the base branch. Keep this case failing +until the dependency satisfies it. A passing menu gate does not imply a +passing recorder gate, and neither implies complete Gancho or release acceptance. + +The script prints a fresh `gancho-shortcut-menu.*` evidence directory and +retains its manifest, JSON trace or recorder snapshot, log, and `.xcresult`. +The manifest records production-source hashes, separate UUID identities, and +the exact locked dependency revision. These gates do not prove full Gancho +startup, Settings/onboarding recorder integration, the installed app, or +runtime behavior on macOS versions not actually tested. diff --git a/Tests/KeyboardShortcutsAcceptance/RecorderFixtureApp.swift b/Tests/KeyboardShortcutsAcceptance/RecorderFixtureApp.swift new file mode 100644 index 00000000..3ab178c2 --- /dev/null +++ b/Tests/KeyboardShortcutsAcceptance/RecorderFixtureApp.swift @@ -0,0 +1,244 @@ +import AppKit +import KeyboardShortcuts +import Observation +import SwiftUI + +/// The real dependency recorder with synthetic preferences and inert handlers. +/// Gancho's application, capture, helper, and purchase lifecycles never start. +@Observable +@MainActor +final class RecorderFixtureState { + var panelDown = 0 { didSet { writeSnapshot() } } + var panelUp = 0 { didSet { writeSnapshot() } } + var cyclicDown = 0 { didSet { writeSnapshot() } } + var cyclicUp = 0 { didSet { writeSnapshot() } } + var changes = 0 { didSet { writeSnapshot() } } + var recordingActive = false { didSet { writeSnapshot() } } + var shortcutWarning: String? + let snapshotURL: URL + let identifier: String + let seededValues: String + var displayedRecorders: () -> [String: String] = { [:] } + var boundMenuShortcut: () -> (key: String, modifiers: UInt) = { ("", 0) } + private var recordingObserver: NSObjectProtocol? + + init() { + let arguments = CommandLine.arguments + let prefix = "com.johnny4young.gancho.tests.shortcut-recorder." + guard let identifier = Bundle.main.bundleIdentifier, + identifier.hasPrefix(prefix), + identifier.dropFirst(prefix.count).count == 32, + identifier.dropFirst(prefix.count).allSatisfy({ "0123456789abcdef".contains($0) }), + let index = arguments.firstIndex(of: "-fixture-snapshot"), + arguments.indices.contains(index + 1) + else { fatalError("An isolated recorder identity and snapshot are required") } + self.identifier = identifier + snapshotURL = URL(fileURLWithPath: arguments[index + 1]).standardizedFileURL + let rootName = snapshotURL.deletingLastPathComponent().lastPathComponent + guard snapshotURL.lastPathComponent == "recorder-snapshot.json", + rootName.hasPrefix("gancho-shortcut-menu.") + || rootName.hasPrefix("gancho-shortcut-recorder.") + else { fatalError("Snapshot must belong to the disposable fixture directory") } + + if arguments.contains("-fixture-seed") { + // Seed storage before accessing a Name: its initializer may install + // a default, so using setShortcut here would not prove restoration. + UserDefaults.standard.removePersistentDomain(forName: identifier) + UserDefaults.standard.setPersistentDomain( + [ + "KeyboardShortcuts_toggle-panel": + #"{"carbonKeyCode":9,"carbonModifiers":6912}"#, + "KeyboardShortcuts_cyclic-paste": + #"{"carbonKeyCode":40,"carbonModifiers":6912}"#, + "KeyboardShortcuts_copy-screen-text": + #"{"carbonKeyCode":17,"carbonModifiers":6912}"# + ], forName: identifier) + UserDefaults.standard.synchronize() + } + seededValues = Self.storedValues(identifier: identifier) + + KeyboardShortcuts.onKeyDown(for: .togglePanel) { [weak self] in + self?.panelDown += 1 + } + KeyboardShortcuts.onKeyUp(for: .togglePanel) { [weak self] in + self?.panelUp += 1 + } + KeyboardShortcuts.onKeyDown(for: .cyclicPaste) { [weak self] in + self?.cyclicDown += 1 + } + KeyboardShortcuts.onKeyUp(for: .cyclicPaste) { [weak self] in + self?.cyclicUp += 1 + } + // A notification emitted by the actual native recorder; no simulated + // recording state or dependency-source modification is involved. + recordingObserver = NotificationCenter.default.addObserver( + forName: Notification.Name("KeyboardShortcuts_recorderActiveStatusDidChange"), + object: nil, queue: .main + ) { [weak self] notification in + let active = notification.userInfo?["isActive"] as? Bool ?? false + Task { @MainActor [weak self] in self?.recordingActive = active } + } + writeSnapshot() + } + + private static func storedValues(identifier: String) -> String { + let values = (UserDefaults.standard.persistentDomain(forName: identifier) ?? [:]) + .filter { $0.key.hasPrefix("KeyboardShortcuts_") } + guard + let data = try? JSONSerialization.data(withJSONObject: values, options: [.sortedKeys]), + let encoded = String(data: data, encoding: .utf8) + else { fatalError("Synthetic shortcut preferences could not be encoded") } + return encoded + } + + func shortcutChanged(_ shortcut: KeyboardShortcuts.Shortcut?) { + shortcutWarning = shortcut.flatMap { ShortcutConflicts.conflict(with: "\($0)") } + changes += 1 + } + + func writeSnapshot() { + let panel = KeyboardShortcuts.getShortcut(for: .togglePanel) + let cyclic = KeyboardShortcuts.getShortcut(for: .cyclicPaste) + let displayed = displayedRecorders() + let bound = boundMenuShortcut() + let state: [String: Any] = [ + "bundleIdentifier": identifier, + "version": Bundle.main.object(forInfoDictionaryKey: "FixtureDependencyVersion") + as? String ?? "missing", + "revision": Bundle.main.object(forInfoDictionaryKey: "FixtureDependencyRevision") + as? String ?? "missing", + "seededValues": seededValues, + "storedValues": Self.storedValues(identifier: identifier), + "panelDown": panelDown, "panelUp": panelUp, + "cyclicDown": cyclicDown, "cyclicUp": cyclicUp, + "changes": changes, "recordingActive": recordingActive, + "recordersReady": displayed["toggle-panel"] != nil && displayed["cyclic-paste"] != nil, + "panelKeyCode": panel?.carbonKeyCode ?? -1, + "panelModifiers": panel?.carbonModifiers ?? -1, + "cyclicKeyCode": cyclic?.carbonKeyCode ?? -1, + "cyclicModifiers": cyclic?.carbonModifiers ?? -1, + "panelEnabled": KeyboardShortcuts.isEnabled(for: .togglePanel), + "cyclicEnabled": KeyboardShortcuts.isEnabled(for: .cyclicPaste), + "panelDisplay": displayed["toggle-panel"] ?? "", + "cyclicDisplay": displayed["cyclic-paste"] ?? "", + "boundMenuKey": bound.key, "boundMenuModifiers": bound.modifiers + ] + do { + let data = try JSONSerialization.data( + withJSONObject: state, options: [.sortedKeys, .prettyPrinted]) + try data.write(to: snapshotURL, options: .atomic) + } catch { + fatalError("Could not save synthetic recorder state: \(error)") + } + } +} + +private struct RecorderFixtureView: View { + let state: RecorderFixtureState + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Shortcut Recorder Fixture").font(.title) + KeyboardShortcuts.Recorder("Panel shortcut:", name: .togglePanel) { shortcut in + state.shortcutChanged(shortcut) + } + .accessibilityIdentifier("panel-shortcut-recorder") + KeyboardShortcuts.Recorder("Cyclic paste shortcut:", name: .cyclicPaste) { shortcut in + state.shortcutChanged(shortcut) + } + .accessibilityIdentifier("cyclic-shortcut-recorder") + if let warning = state.shortcutWarning { + Text("This shortcut is used by the system (\(warning)).") + } + Button("Stop recording") { NSApp.keyWindow?.makeFirstResponder(nil) } + .accessibilityIdentifier("stop-recording-button") + Text(state.recordingActive ? "Recording" : "Ready") + .accessibilityIdentifier("recorder-state-text") + Text( + "Panel \(state.panelDown)/\(state.panelUp); cyclic \(state.cyclicDown)/\(state.cyclicUp)" + ) + .font(.system(.body, design: .monospaced)) + } + .padding(28) + .frame(width: 540, height: 260) + } +} + +@main +@MainActor +final class ShortcutRecorderFixture: NSObject, NSApplicationDelegate { + private var window: NSWindow? + private var state: RecorderFixtureState? + private var snapshotTimer: Timer? + + static func main() { + let app = NSApplication.shared + let delegate = ShortcutRecorderFixture() + app.delegate = delegate + withExtendedLifetime(delegate) { app.run() } + } + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + let state = RecorderFixtureState() + self.state = state + + let fixtureMenu = NSMenu(title: "Fixture Menu") + let reserved = NSMenuItem( + title: "Reserved Fixture Action", action: #selector(reservedAction), keyEquivalent: "j") + reserved.target = self + reserved.keyEquivalentModifierMask = .command + fixtureMenu.addItem(reserved) + let bound = NSMenuItem( + title: "Current Panel Binding", action: #selector(reservedAction), keyEquivalent: "") + bound.target = self + bound.setShortcut(for: .togglePanel) + fixtureMenu.addItem(bound) + state.boundMenuShortcut = { [weak bound] in + (bound?.keyEquivalent ?? "", bound?.keyEquivalentModifierMask.rawValue ?? 0) + } + let mainMenu = NSMenu() + let item = NSMenuItem(title: "Fixture Menu", action: nil, keyEquivalent: "") + item.submenu = fixtureMenu + mainMenu.addItem(item) + NSApp.mainMenu = mainMenu + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 596, height: 316), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.title = "Shortcut Recorder Fixture" + window.contentView = NSHostingView(rootView: RecorderFixtureView(state: state)) + state.displayedRecorders = { [weak window] in + Self.recorders(in: window?.contentView).reduce(into: [:]) { values, recorder in + values[recorder.shortcutName.rawValue] = recorder.stringValue + } + } + window.center() + window.makeKeyAndOrderFront(nil) + self.window = window + snapshotTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { + [weak state] _ in + Task { @MainActor [weak state] in state?.writeSnapshot() } + } + NSRunningApplication.current.activate(options: [.activateAllWindows]) + state.writeSnapshot() + } + + private static func recorders(in view: NSView?) -> [KeyboardShortcuts.RecorderCocoa] { + guard let view else { return [] } + if let recorder = view as? KeyboardShortcuts.RecorderCocoa { return [recorder] } + return view.subviews.flatMap { recorders(in: $0) } + } + + @objc private func reservedAction() {} + + func applicationWillTerminate(_ notification: Notification) { + snapshotTimer?.invalidate() + state?.writeSnapshot() + // Preserve this UUID domain across test-owned relaunches. The runner + // removes it only after each complete case, including failure paths. + UserDefaults.standard.synchronize() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } +} diff --git a/Tests/KeyboardShortcutsAcceptance/ShortcutMenuUITests.swift b/Tests/KeyboardShortcutsAcceptance/ShortcutMenuUITests.swift new file mode 100644 index 00000000..2f1c4267 --- /dev/null +++ b/Tests/KeyboardShortcutsAcceptance/ShortcutMenuUITests.swift @@ -0,0 +1,167 @@ +import AppKit +import XCTest + +final class ShortcutMenuUITests: XCTestCase { + @MainActor + func testConsumedMenuShortcutIsDeliveredOnceWithoutRequeue() async throws { + let environment = ProcessInfo.processInfo.environment + let snapshotPath = try XCTUnwrap(environment["SHORTCUT_MENU_SNAPSHOT"]) + let bundleIdentifier = try XCTUnwrap(environment["SHORTCUT_MENU_BUNDLE_ID"]) + let app = XCUIApplication(bundleIdentifier: bundleIdentifier) + app.launchArguments = ["-fixture-snapshot", snapshotPath] + app.launch() + defer { app.terminate() } + let windowExists = try await waitForElement( + app.windows["Shortcut Menu Fixture"], seconds: 8) + guard windowExists else { + XCTFail("The fixture window did not appear") + return + } + let initial = try readSnapshot(snapshotPath) + guard + initial["version"] as? String == environment["SHORTCUT_MENU_VERSION"], + initial["revision"] as? String == environment["SHORTCUT_MENU_REVISION"], + initial["bundleIdentifier"] as? String == bundleIdentifier + else { + XCTFail("The fixture snapshot does not match the canonical dependency and identity") + return + } + try requireRegisteredChord(initial) + + try requireForeground(app, bundleIdentifier: bundleIdentifier) + let openMenu = app.buttons["open-menu-button"] + guard openMenu.isHittable else { + XCTFail("The fixture menu button is not hittable") + return + } + openMenu.click() + let menuExists = try await waitForElement( + app.menuItems["Reserved Fixture Action"], seconds: 3) + guard menuExists else { + XCTFail("The fixture menu did not appear") + return + } + guard try readSnapshot(snapshotPath)["menuTracking"] as? Bool == true else { + XCTFail("The fixture is not tracking its popup menu") + return + } + + // XCUITest synthesizes global input; abort if the fixture loses focus. + try requireForeground(app, bundleIdentifier: bundleIdentifier) + app.typeKey("v", modifierFlags: [.command, .control, .option, .shift]) + let during = try readSnapshot(snapshotPath) + try attach(during, name: "menu-after-one-native-key-pair") + XCTAssertEqual(during["keyDownCount"] as? Int, 1) + XCTAssertEqual(during["keyUpCount"] as? Int, 1) + XCTAssertEqual(during["menuTracking"] as? Bool, true) + try verifyConsumedEvents(during) + + try requireForeground(app, bundleIdentifier: bundleIdentifier) + app.typeKey(.escape, modifierFlags: []) + try await observeNoDelayedReplay(at: snapshotPath) + let closed = try readSnapshot(snapshotPath) + try attach(closed, name: "menu-closed-without-replayed-shortcut") + XCTAssertEqual(closed["menuTracking"] as? Bool, false) + XCTAssertEqual(closed["keyDownCount"] as? Int, 1) + XCTAssertEqual(closed["keyUpCount"] as? Int, 1) + try verifyConsumedEvents(closed) + } + + private func requireRegisteredChord(_ snapshot: [String: Any]) throws { + guard + snapshot["keyDownCount"] as? Int == 0, + snapshot["keyUpCount"] as? Int == 0, + snapshot["shortcutEnabled"] as? Bool == true, + snapshot["shortcutKeyCode"] as? Int == 9, + snapshot["shortcutModifiers"] as? Int == 6912 + else { + throw NSError( + domain: "ShortcutMenuFixture", code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "The synthetic chord must be registered and unused before native input" + ]) + } + } + + @MainActor + private func requireForeground(_ app: XCUIApplication, bundleIdentifier: String) throws { + guard + app.state == .runningForeground, + NSWorkspace.shared.frontmostApplication?.bundleIdentifier == bundleIdentifier + else { + throw NSError( + domain: "ShortcutMenuFixture", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "The fixture is not frontmost; native acceptance cannot synthesize input" + ]) + } + } + + private func verifyConsumedEvents(_ snapshot: [String: Any]) throws { + let trace = try XCTUnwrap(snapshot["trace"] as? [[String: Any]]) + let callbacks = trace.filter { + ["down-callback", "up-callback"].contains($0["phase"] as? String ?? "") + } + XCTAssertEqual(callbacks.count, 2) + for callback in callbacks { + XCTAssertEqual(callback["menuTracking"] as? Bool, true) + XCTAssertEqual(callback["isRepeat"] as? Bool, false) + let timestamp = try XCTUnwrap(callback["timestamp"] as? Double) + let type = try XCTUnwrap(callback["type"] as? Int) + let expectedType = + callback["phase"] as? String == "down-callback" + ? NSEvent.EventType.keyDown : .keyUp + XCTAssertEqual(type, Int(expectedType.rawValue)) + let dequeued = trace.filter { + $0["phase"] as? String == "dequeue" + && $0["timestamp"] as? Double == timestamp && $0["type"] as? Int == type + } + XCTAssertEqual(dequeued.count, 1, "The same native event must be dequeued once") + XCTAssertEqual(dequeued.first?["mode"] as? String, RunLoop.Mode.eventTracking.rawValue) + } + XCTAssertFalse(trace.contains { $0["phase"] as? String == "repost" }) + XCTAssertFalse(trace.contains { $0["phase"] as? String == "send" }) + } + + @MainActor + private func waitForElement(_ element: XCUIElement, seconds: Int) async throws -> Bool { + for _ in 0..<(seconds * 20) { + if element.exists { return true } + try await Task.sleep(for: .milliseconds(50)) + } + return false + } + + @MainActor + private func observeNoDelayedReplay(at path: String) async throws { + for _ in 0..<20 { + let snapshot = try readSnapshot(path) + let trace = try XCTUnwrap(snapshot["trace"] as? [[String: Any]]) + if snapshot["keyDownCount"] as? Int != 1 || snapshot["keyUpCount"] as? Int != 1 + || trace.contains(where: { + ["repost", "send"].contains($0["phase"] as? String ?? "") + }) + { + XCTFail("A consumed shortcut was replayed after menu close") + return + } + try await Task.sleep(for: .milliseconds(50)) + } + } + + private func readSnapshot(_ path: String) throws -> [String: Any] { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private func attach(_ snapshot: [String: Any], name: String) throws { + let data = try JSONSerialization.data( + withJSONObject: snapshot, options: [.sortedKeys, .prettyPrinted]) + let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json") + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Tests/KeyboardShortcutsAcceptance/ShortcutRecorderUITests.swift b/Tests/KeyboardShortcutsAcceptance/ShortcutRecorderUITests.swift new file mode 100644 index 00000000..d6c30c67 --- /dev/null +++ b/Tests/KeyboardShortcutsAcceptance/ShortcutRecorderUITests.swift @@ -0,0 +1,534 @@ +import AppKit +import XCTest + +final class ShortcutRecorderUITests: XCTestCase { + @MainActor + func testStoredShortcutsRestoreAndDispatchOnce() async throws { + let configuration = try RecorderConfiguration() + let app = XCUIApplication(bundleIdentifier: configuration.bundleIdentifier) + defer { cleanUp(app, configuration: configuration) } + let initial = try await launch(app, configuration: configuration, seed: true) + + try typeKey("v", in: app, configuration: configuration, modifiers: allModifiers) + _ = try await waitForSnapshot(configuration) { $0.panelDown == 1 && $0.panelUp == 1 } + try typeKey("k", in: app, configuration: configuration, modifiers: allModifiers) + let delivered = try await waitForSnapshot(configuration) { + $0.cyclicDown == 1 && $0.cyclicUp == 1 + } + try await observeCounters(configuration, panel: 1, cyclic: 1) + XCTAssertEqual(delivered.storedValues, initial.seededValues) + XCTAssertEqual(delivered.changes, 0) + try attach(delivered, name: "restored-recorder-bindings-delivered-once") + } + + @MainActor + func testCancelSwitchAndOutsideClickPreserveBindings() async throws { + let configuration = try RecorderConfiguration() + let app = XCUIApplication(bundleIdentifier: configuration.bundleIdentifier) + defer { cleanUp(app, configuration: configuration) } + let initial = try await launch(app, configuration: configuration, seed: true) + + try await beginRecording("panel-shortcut-recorder", in: app, configuration: configuration) + try typeKey( + XCUIKeyboardKey.escape.rawValue, in: app, configuration: configuration, + recorder: "panel-shortcut-recorder") + let cancelled = try await waitForSnapshot(configuration) { !$0.recordingActive } + try require( + cancelled.storedValues == initial.seededValues, + "Escape changed a persisted shortcut before the next recording gesture") + + try await beginRecording("panel-shortcut-recorder", in: app, configuration: configuration) + try await beginRecording("cyclic-shortcut-recorder", in: app, configuration: configuration) + try typeKey( + XCUIKeyboardKey.escape.rawValue, in: app, configuration: configuration, + recorder: "cyclic-shortcut-recorder") + let switched = try await waitForSnapshot(configuration) { !$0.recordingActive } + try require( + switched.storedValues == initial.seededValues, + "Switching recorders changed a persisted shortcut") + + try await beginRecording("panel-shortcut-recorder", in: app, configuration: configuration) + try click(app.buttons["stop-recording-button"], in: app, configuration: configuration) + let stopped = try await waitForSnapshot(configuration) { !$0.recordingActive } + try require( + stopped.storedValues == initial.seededValues && stopped.changes == 0, + "Cancelling recording changed the binding before dispatch resumed") + try await observeCounters(configuration, panel: 0, cyclic: 0) + + try typeKey("v", in: app, configuration: configuration, modifiers: allModifiers) + let resumed = try await waitForSnapshot(configuration) { + $0.panelDown == 1 && $0.panelUp == 1 + } + try await observeCounters(configuration, panel: 1, cyclic: 0) + try attach(resumed, name: "cancelled-recorders-preserved-bindings-and-resumed") + } + + @MainActor + func testRerecordOwnBindingAvoidsConflictAndRecordingCallbacks() async throws { + let configuration = try RecorderConfiguration() + let app = XCUIApplication(bundleIdentifier: configuration.bundleIdentifier) + defer { cleanUp(app, configuration: configuration) } + let initial = try await launch(app, configuration: configuration, seed: true) + try require( + initial.boundMenuKey == "v" && initial.boundMenuModifiers == 1_966_080, + "The own-binding menu prerequisite was not established before re-recording") + + try await beginRecording("panel-shortcut-recorder", in: app, configuration: configuration) + try typeKey( + "v", in: app, configuration: configuration, modifiers: allModifiers, + recorder: "panel-shortcut-recorder") + let recorded = try await waitForSnapshot(configuration) { + !$0.recordingActive && $0.changes == 1 + } + try require( + !app.sheets.firstMatch.exists && !app.dialogs.firstMatch.exists, + "Re-recording the own binding opened an unexpected conflict alert") + try require( + try preferences(recorded.storedValues) == preferences(initial.seededValues), + "Re-recording the own binding changed its persisted meaning") + try await observeCounters(configuration, panel: 0, cyclic: 0) + + try typeKey("v", in: app, configuration: configuration, modifiers: allModifiers) + let resumed = try await waitForSnapshot(configuration) { + $0.panelDown == 1 && $0.panelUp == 1 + } + try await observeCounters(configuration, panel: 1, cyclic: 0) + try attach(resumed, name: "own-binding-rerecorded-without-action-or-conflict") + } + + @MainActor + func testReplacementDeactivatesOldBindingAndSurvivesRelaunch() async throws { + let configuration = try RecorderConfiguration() + let app = XCUIApplication(bundleIdentifier: configuration.bundleIdentifier) + defer { cleanUp(app, configuration: configuration) } + let initial = try await launch(app, configuration: configuration, seed: true) + + try await beginRecording("panel-shortcut-recorder", in: app, configuration: configuration) + try typeKey( + "u", in: app, configuration: configuration, modifiers: allModifiers, + recorder: "panel-shortcut-recorder") + let replacement = try await waitForSnapshot(configuration) { + !$0.recordingActive && $0.changes == 1 && $0.panelKeyCode == 32 + && $0.boundMenuKey == "u" + } + try requirePanel(replacement, keyCode: 32, key: "U") + var expected = try preferences(initial.seededValues) + expected["KeyboardShortcuts_toggle-panel"] = .shortcut(keyCode: 32, modifiers: 6912) + try require( + try preferences(replacement.storedValues) == expected, + "Recording a replacement changed another shortcut") + try await observeCounters(configuration, panel: 0, cyclic: 0) + + try typeKey("v", in: app, configuration: configuration, modifiers: allModifiers) + try await observeCounters(configuration, panel: 0, cyclic: 0) + try typeKey("u", in: app, configuration: configuration, modifiers: allModifiers) + _ = try await waitForSnapshot(configuration) { $0.panelDown == 1 && $0.panelUp == 1 } + try await observeCounters(configuration, panel: 1, cyclic: 0) + + app.terminate() + let restored = try await launch(app, configuration: configuration, seed: false) + try require( + restored.storedValues == replacement.storedValues, + "The replacement binding changed during a real relaunch") + try requirePanel(restored, keyCode: 32, key: "U") + try typeKey("u", in: app, configuration: configuration, modifiers: allModifiers) + let delivered = try await waitForSnapshot(configuration) { + $0.panelDown == 1 && $0.panelUp == 1 + } + try await observeCounters(configuration, panel: 1, cyclic: 0) + XCTAssertEqual(delivered.storedValues, replacement.storedValues) + try attach(delivered, name: "replacement-binding-restored-after-real-relaunch") + } + + @MainActor + func testConflictPreservesBindingAndNativeClearSurvivesRelaunch() async throws { + let configuration = try RecorderConfiguration() + let app = XCUIApplication(bundleIdentifier: configuration.bundleIdentifier) + defer { cleanUp(app, configuration: configuration) } + let initial = try await launch(app, configuration: configuration, seed: true) + + try await beginRecording("panel-shortcut-recorder", in: app, configuration: configuration) + try typeKey( + "j", in: app, configuration: configuration, modifiers: .command, + recorder: "panel-shortcut-recorder") + let dismiss = app.buttons["OK"].firstMatch + try await waitForElement(dismiss) + let blocked = try readSnapshot(configuration) + try require( + blocked.storedValues == initial.seededValues && blocked.changes == 0, + "The menu conflict changed a persisted binding before alert dismissal") + try click(dismiss, in: app, configuration: configuration) + try await waitForRecordingFocus( + "panel-shortcut-recorder", in: app, + configuration: configuration) + try typeKey( + XCUIKeyboardKey.escape.rawValue, in: app, configuration: configuration, + recorder: "panel-shortcut-recorder") + let rejected = try await waitForSnapshot(configuration) { !$0.recordingActive } + try require( + rejected.storedValues == initial.seededValues && rejected.changes == 0, + "Rejecting the menu conflict changed a persisted binding") + try requirePanel(rejected, keyCode: 9, key: "V") + try typeKey("v", in: app, configuration: configuration, modifiers: allModifiers) + _ = try await waitForSnapshot(configuration) { $0.panelDown == 1 && $0.panelUp == 1 } + + try await beginRecording("cyclic-shortcut-recorder", in: app, configuration: configuration) + try typeKey( + XCUIKeyboardKey.delete.rawValue, in: app, configuration: configuration, + recorder: "cyclic-shortcut-recorder") + _ = try await waitForSnapshot(configuration) { + $0.cyclicKeyCode == -1 && $0.cyclicModifiers == -1 && !$0.cyclicEnabled + } + try click(app.buttons["stop-recording-button"], in: app, configuration: configuration) + let cleared = try await waitForSnapshot(configuration) { !$0.recordingActive } + var expected = try preferences(initial.seededValues) + expected.removeValue(forKey: "KeyboardShortcuts_cyclic-paste") + try require( + try preferences(cleared.storedValues) == expected, + "Native clear changed a shortcut other than the optional cyclic binding") + try requireCyclicCleared(cleared) + try typeKey("k", in: app, configuration: configuration, modifiers: allModifiers) + try await observeCounters(configuration, panel: 1, cyclic: 0) + + app.terminate() + let restored = try await launch(app, configuration: configuration, seed: false) + try require( + restored.storedValues == cleared.storedValues, + "The cleared optional binding changed during a real relaunch") + try requireCyclicCleared(restored) + try typeKey("k", in: app, configuration: configuration, modifiers: allModifiers) + try await observeCounters(configuration, panel: 0, cyclic: 0) + try attach(restored, name: "menu-conflict-rejected-and-optional-binding-cleared") + } +} + +extension ShortcutRecorderUITests { + private var allModifiers: XCUIElement.KeyModifierFlags { + [.command, .control, .option, .shift] + } + + @MainActor + private func launch( + _ app: XCUIApplication, configuration: RecorderConfiguration, seed: Bool + ) async throws -> RecorderSnapshot { + app.launchArguments = + [ + "-fixture-snapshot", configuration.snapshotURL.path, "-AppleLanguages", "(en)" + ] + (seed ? ["-fixture-seed"] : []) + app.launch() + try await waitForElement(app.windows["Shortcut Recorder Fixture"], seconds: 8) + app.activate() + for _ in 0..<100 { + if app.state == .runningForeground, + NSWorkspace.shared.frontmostApplication?.bundleIdentifier + == configuration.bundleIdentifier + { + break + } + try await Task.sleep(for: .milliseconds(50)) + } + try requireForeground(app, configuration: configuration) + let initial = try await waitForSnapshot(configuration) { + $0.recordersReady && !$0.recordingActive && !$0.panelDisplay.isEmpty + && (!seed || !$0.cyclicDisplay.isEmpty) + && $0.changes == 0 && $0.panelDown == 0 && $0.panelUp == 0 + && $0.cyclicDown == 0 && $0.cyclicUp == 0 + } + if seed { + try require( + initial.storedValues == initial.seededValues, + "The initial names rewrote the raw seeded preferences") + try requirePanel(initial, keyCode: 9, key: "V") + try require( + initial.cyclicKeyCode == 40 && initial.cyclicModifiers == 6912 + && initial.cyclicEnabled, + "The optional cyclic binding did not restore and register") + try requireDisplay(initial.cyclicDisplay, key: "K") + } + return initial + } + + @MainActor + private func cleanUp(_ app: XCUIApplication, configuration: RecorderConfiguration) { + app.terminate() + // Configuration validates the complete UUID fixture identity before cleanup. + UserDefaults.standard.removePersistentDomain(forName: configuration.bundleIdentifier) + } + + @MainActor + private func requireForeground( + _ app: XCUIApplication, configuration: RecorderConfiguration + ) throws { + let state = app.state + let frontmost = NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "missing" + let mainWindow = app.windows["Shortcut Recorder Fixture"].exists + let dialog = app.dialogs.firstMatch.exists + let sheet = app.sheets.firstMatch.exists + // A modal native alert can hide its owning main window from AX. A + // dialog/sheet must belong to this XCUIApplication, while both process + // ownership checks remain mandatory before any input. + guard state == .runningForeground, frontmost == configuration.bundleIdentifier, + mainWindow || dialog || sheet + else { + attachFailureSnapshot(configuration, name: "recorder-foreground-failure") + throw fixtureError( + "Fixture foreground guard failed: appState=\(state), frontmostUUID=\(frontmost), " + + "expectedUUID=\(configuration.bundleIdentifier), mainWindow=\(mainWindow), " + + "dialog=\(dialog), sheet=\(sheet)") + } + } + + @MainActor + private func click( + _ element: XCUIElement, in app: XCUIApplication, configuration: RecorderConfiguration + ) throws { + try require( + readSnapshot(configuration).recordersReady, + "The native recorder views are not ready before fixture input") + guard element.exists, element.isHittable, !element.frame.isEmpty, !element.frame.isInfinite + else { throw fixtureError("The recorder fixture click target is missing or not hittable") } + try requireForeground(app, configuration: configuration) + element.click() + } + + @MainActor + private func typeKey( + _ key: String, in app: XCUIApplication, configuration: RecorderConfiguration, + modifiers: XCUIElement.KeyModifierFlags = [], recorder: String? = nil + ) throws { + let snapshot = try readSnapshot(configuration) + try require( + snapshot.recordersReady, + "The native recorder views are not ready before fixture input") + if let recorder { + let field = app.searchFields[recorder].firstMatch + guard snapshot.recordingActive, field.exists, field.isHittable, + (field.value(forKey: "hasKeyboardFocus") as? Bool) == true + else { throw fixtureError("The intended native recorder does not hold keyboard focus") } + } else { + guard !snapshot.recordingActive else { + throw fixtureError("The fixture is still recording before shortcut dispatch") + } + } + // XCUITest keys are global. A focus change after this check remains a host race. + try requireForeground(app, configuration: configuration) + app.typeKey(key, modifierFlags: modifiers) + } + + @MainActor + private func beginRecording( + _ identifier: String, in app: XCUIApplication, configuration: RecorderConfiguration + ) async throws { + let recorder = app.searchFields[identifier].firstMatch + try await waitForElement(recorder) + try click(recorder, in: app, configuration: configuration) + try await waitForRecordingFocus(identifier, in: app, configuration: configuration) + } + + @MainActor + private func waitForRecordingFocus( + _ identifier: String, in app: XCUIApplication, configuration: RecorderConfiguration + ) async throws { + let recorder = app.searchFields[identifier].firstMatch + for _ in 0..<100 { + if try readSnapshot(configuration).recordingActive, + recorder.exists, (recorder.value(forKey: "hasKeyboardFocus") as? Bool) == true + { + return + } + try await Task.sleep(for: .milliseconds(50)) + } + attachFailureSnapshot(configuration, name: "recorder-focus-timeout") + let exists = recorder.exists + let focused = exists && (recorder.value(forKey: "hasKeyboardFocus") as? Bool) == true + throw fixtureError( + "Native recorder focus timed out: recorder=\(identifier), exists=\(exists), " + + "hasKeyboardFocus=\(focused)") + } + + @MainActor + private func waitForElement(_ element: XCUIElement, seconds: Int = 5) async throws { + for _ in 0..<(seconds * 20) { + if element.exists, element.isHittable { return } + try await Task.sleep(for: .milliseconds(50)) + } + throw fixtureError("The expected recorder fixture element did not become hittable") + } + + @MainActor + private func waitForSnapshot( + _ configuration: RecorderConfiguration, matching predicate: (RecorderSnapshot) -> Bool + ) async throws -> RecorderSnapshot { + for _ in 0..<100 { + if FileManager.default.fileExists(atPath: configuration.snapshotURL.path) { + let snapshot = try readSnapshot(configuration) + if predicate(snapshot) { return snapshot } + } + try await Task.sleep(for: .milliseconds(50)) + } + attachFailureSnapshot(configuration, name: "recorder-state-timeout") + throw fixtureError("The recorder fixture snapshot did not reach the expected state") + } + + @MainActor + private func observeCounters( + _ configuration: RecorderConfiguration, panel: Int, cyclic: Int + ) async throws { + for _ in 0..<10 { + let snapshot = try readSnapshot(configuration) + guard snapshot.panelDown == panel, snapshot.panelUp == panel, + snapshot.cyclicDown == cyclic, snapshot.cyclicUp == cyclic + else { + attachFailureSnapshot(configuration, name: "recorder-callback-failure") + throw fixtureError("A native shortcut callback was missing, duplicated, or leaked") + } + try await Task.sleep(for: .milliseconds(50)) + } + } + + private func readSnapshot(_ configuration: RecorderConfiguration) throws -> RecorderSnapshot { + let snapshot = try JSONDecoder().decode( + RecorderSnapshot.self, from: Data(contentsOf: configuration.snapshotURL)) + guard snapshot.bundleIdentifier == configuration.bundleIdentifier, + snapshot.version == configuration.version, snapshot.revision == configuration.revision + else { throw fixtureError("The recorder snapshot has a different identity or dependency") } + return snapshot + } + + private func require(_ condition: Bool, _ description: String) throws { + guard condition else { throw fixtureError(description) } + } + + private func requirePanel(_ snapshot: RecorderSnapshot, keyCode: Int, key: String) throws { + try require( + snapshot.panelKeyCode == keyCode && snapshot.panelModifiers == 6912 + && snapshot.panelEnabled && snapshot.boundMenuKey == key.lowercased() + && snapshot.boundMenuModifiers == 1_966_080, + "The expected panel binding, registration, and menu binding are not established") + try requireDisplay(snapshot.panelDisplay, key: key) + } + + private func requireCyclicCleared(_ snapshot: RecorderSnapshot) throws { + try require( + snapshot.cyclicKeyCode == -1 && snapshot.cyclicModifiers == -1 + && !snapshot.cyclicEnabled && snapshot.cyclicDisplay.isEmpty, + "The cleared cyclic binding is still stored, registered, or displayed") + } + + private func requireDisplay(_ display: String, key: String) throws { + try require( + display.uppercased().hasSuffix(key) + && ["⌃", "⌥", "⇧", "⌘"].allSatisfy { display.contains($0) }, + "The native recorder does not display the expected key and every modifier") + } + + private func preferences(_ encoded: String) throws -> [String: RecorderPreference] { + let values = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(encoded.utf8)) as? [String: Any]) + var decoded: [String: RecorderPreference] = [:] + for (key, value) in values { + guard key.hasPrefix("KeyboardShortcuts_") else { + throw fixtureError("The fixture snapshot contains a non-shortcut preference") + } + if let shortcut = value as? String { + let fields = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(shortcut.utf8)) as? [String: Int]) + decoded[key] = .shortcut( + keyCode: try XCTUnwrap(fields["carbonKeyCode"]), + modifiers: try XCTUnwrap(fields["carbonModifiers"])) + } else if value as? Bool == false { + decoded[key] = .disabled + } else { + throw fixtureError("The fixture contains an unsupported shortcut storage value") + } + } + return decoded + } + + private func attach(_ snapshot: RecorderSnapshot, name: String) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + let attachment = XCTAttachment( + data: try encoder.encode(snapshot), uniformTypeIdentifier: "public.json") + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + private func attachFailureSnapshot(_ configuration: RecorderConfiguration, name: String) { + do { + try attach(readSnapshot(configuration), name: name) + } catch { + let attachment = XCTAttachment( + string: "Synthetic recorder failure snapshot unavailable: \(error)") + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + } +} + +private struct RecorderConfiguration: Sendable { + let bundleIdentifier: String + let snapshotURL: URL + let version: String + let revision: String + + init() throws { + let environment = ProcessInfo.processInfo.environment + bundleIdentifier = try XCTUnwrap(environment["SHORTCUT_RECORDER_BUNDLE_ID"]) + snapshotURL = + URL(fileURLWithPath: try XCTUnwrap(environment["SHORTCUT_RECORDER_SNAPSHOT"])) + .standardizedFileURL + version = try XCTUnwrap(environment["SHORTCUT_MENU_VERSION"]) + revision = try XCTUnwrap(environment["SHORTCUT_MENU_REVISION"]) + let domainPattern = + "^com\\.johnny4young\\.gancho\\.tests\\.shortcut-recorder\\.[0-9a-f]{32}$" + let directory = snapshotURL.deletingLastPathComponent().lastPathComponent + guard bundleIdentifier.range(of: domainPattern, options: .regularExpression) != nil, + snapshotURL.pathExtension == "json", + directory.hasPrefix("gancho-shortcut-menu.") + || directory.hasPrefix("gancho-shortcut-recorder."), + version.range(of: "^[0-9]+\\.[0-9]+\\.[0-9]+$", options: .regularExpression) != nil, + revision.range(of: "^[0-9a-f]{40}$", options: .regularExpression) != nil + else { + throw fixtureError("The recorder fixture configuration is not isolated and canonical") + } + } +} + +private struct RecorderSnapshot: Codable, Sendable { + let bundleIdentifier: String + let version: String + let revision: String + let seededValues: String + let storedValues: String + let panelDown: Int + let panelUp: Int + let cyclicDown: Int + let cyclicUp: Int + let changes: Int + let recordersReady: Bool + let recordingActive: Bool + let panelKeyCode: Int + let panelModifiers: Int + let cyclicKeyCode: Int + let cyclicModifiers: Int + let panelEnabled: Bool + let cyclicEnabled: Bool + let boundMenuKey: String + let boundMenuModifiers: Int + let panelDisplay: String + let cyclicDisplay: String +} + +private enum RecorderPreference: Equatable, Sendable { + case shortcut(keyCode: Int, modifiers: Int) + case disabled +} + +private func fixtureError(_ description: String) -> NSError { + NSError( + domain: "ShortcutRecorderFixture", code: 1, + userInfo: [NSLocalizedDescriptionKey: description]) +} diff --git a/Tests/KeyboardShortcutsAcceptance/prepare.py b/Tests/KeyboardShortcutsAcceptance/prepare.py new file mode 100644 index 00000000..b7521a4b --- /dev/null +++ b/Tests/KeyboardShortcutsAcceptance/prepare.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Generate an isolated native fixture from the canonical, unmodified dependency.""" + +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import uuid + + +def prepare(repo, output, checkout): + lock = json.loads((repo / "Dependencies/Package.resolved").read_text()) + pins = [pin for pin in lock["pins"] if pin["identity"] == "keyboardshortcuts"] + if len(pins) != 1: + raise ValueError("Expected one canonical KeyboardShortcuts pin") + pin = pins[0] + version = pin["state"]["version"] + revision = pin["state"]["revision"] + if not re.fullmatch(r"\d+\.\d+\.\d+", version) or not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ValueError("Invalid canonical KeyboardShortcuts version or revision") + spec = (repo / "project.yml").read_text() + requirement = re.search(r"^ KeyboardShortcuts:\n((?: .*\n)+)", spec, re.MULTILINE) + if not requirement or not re.search( + rf"^ exactVersion: [\"']?{re.escape(version)}[\"']?\s*$", + requirement[1], re.MULTILINE + ): + raise ValueError("project.yml must pin the exact version in the canonical app lock") + canary = (repo / "scripts/upstream-pins.env").read_text() + if not re.search(rf"^KEYBOARDSHORTCUTS={re.escape(version)}$", canary, re.MULTILINE): + raise ValueError("The upstream canary and canonical app lock disagree") + + identity = os.environ.get("SHORTCUT_MENU_SIGN_IDENTITY") + if not identity: + raise ValueError("Set SHORTCUT_MENU_SIGN_IDENTITY to an existing trusted signing identity") + dependency = output / "KeyboardShortcuts" + dependency.mkdir() + archive = subprocess.check_output(["git", "-C", str(checkout), "archive", revision]) + subprocess.run(["tar", "-xf", "-", "-C", str(dependency)], input=archive, check=True) + identifier = "com.johnny4young.gancho.tests.shortcut-menu." + uuid.uuid4().hex + recorder_identifier = "com.johnny4young.gancho.tests.shortcut-recorder." + uuid.uuid4().hex + sources = repo / "Tests/KeyboardShortcutsAcceptance" + build_settings = { + "SWIFT_VERSION": "6.0", + "SWIFT_APPROACHABLE_CONCURRENCY": "YES", + "SWIFT_DEFAULT_ACTOR_ISOLATION": "MainActor", + "SWIFT_STRICT_CONCURRENCY": "complete", + "SWIFT_TREAT_WARNINGS_AS_ERRORS": "YES", + "CODE_SIGN_STYLE": "Manual", + "CODE_SIGN_IDENTITY": identity, + "CODE_SIGN_INJECT_BASE_ENTITLEMENTS": "NO", + "ENABLE_HARDENED_RUNTIME": "YES", + "ENABLE_DEBUG_DYLIB": "NO", + } + project = { + "name": "ShortcutMenuFixture", + "settings": {"base": build_settings}, + "packages": {"KeyboardShortcuts": {"path": str(dependency)}}, + "targets": { + "ShortcutMenuFixture": { + "type": "application", "platform": "macOS", "deploymentTarget": "15.4", + "sources": [ + {"path": str(sources / "FixtureApp.swift")}, + {"path": str(repo / "Apps/GanchoMac/GlobalShortcuts.swift")}, + ], + "info": {"path": "FixtureInfo.plist", "properties": { + "NSPrincipalClass": "MenuFixtureApplication", + "FixtureDependencyVersion": version, "FixtureDependencyRevision": revision, + }}, + "settings": {"base": {"PRODUCT_BUNDLE_IDENTIFIER": identifier}}, + "dependencies": [{"package": "KeyboardShortcuts"}], + }, + "ShortcutMenuUITests": { + "type": "bundle.ui-testing", "platform": "macOS", "deploymentTarget": "15.4", + "sources": [{"path": str(sources / "ShortcutMenuUITests.swift")}], + "settings": {"base": { + "PRODUCT_BUNDLE_IDENTIFIER": identifier + ".runner", + "GENERATE_INFOPLIST_FILE": "YES", + "SWIFT_DEFAULT_ACTOR_ISOLATION": "nonisolated", + "ENABLE_HARDENED_RUNTIME": "NO", + }}, + "dependencies": [{"target": "ShortcutMenuFixture"}], + }, + "ShortcutRecorderFixture": { + "type": "application", "platform": "macOS", "deploymentTarget": "15.4", + "sources": [ + {"path": str(sources / "RecorderFixtureApp.swift")}, + {"path": str(repo / "Apps/GanchoMac/GlobalShortcuts.swift")}, + {"path": str(repo / "Packages/GanchoKit/Sources/ClipboardCore/ShortcutConflicts.swift")}, + ], + "info": {"path": "RecorderFixtureInfo.plist", "properties": { + "FixtureDependencyVersion": version, "FixtureDependencyRevision": revision, + }}, + "settings": {"base": {"PRODUCT_BUNDLE_IDENTIFIER": recorder_identifier}}, + "dependencies": [{"package": "KeyboardShortcuts"}], + }, + "ShortcutRecorderUITests": { + "type": "bundle.ui-testing", "platform": "macOS", "deploymentTarget": "15.4", + "sources": [{"path": str(sources / "ShortcutRecorderUITests.swift")}], + "settings": {"base": { + "PRODUCT_BUNDLE_IDENTIFIER": recorder_identifier + ".runner", + "GENERATE_INFOPLIST_FILE": "YES", + "SWIFT_DEFAULT_ACTOR_ISOLATION": "nonisolated", + "ENABLE_HARDENED_RUNTIME": "NO", + }}, + "dependencies": [{"target": "ShortcutRecorderFixture"}], + }, + }, + "schemes": { + "ShortcutMenuFixture": { + "build": {"targets": {"ShortcutMenuFixture": "all"}}, + "test": {"targets": ["ShortcutMenuUITests"], "gatherCoverageData": False}, + }, + "ShortcutRecorderFixture": { + "build": {"targets": {"ShortcutRecorderFixture": "all"}}, + "test": {"targets": ["ShortcutRecorderUITests"], "gatherCoverageData": False}, + }, + }, + } + (output / "project.json").write_text(json.dumps(project, indent=2) + "\n") + (output / "manifest.json").write_text(json.dumps({ + "version": version, "revision": revision, "bundleIdentifier": identifier, + "sourceCheckout": str(checkout), "sourceRequirement": "Dependencies/Package.resolved", + "snapshotPath": str(output / "snapshot.json"), + "recorderBundleIdentifier": recorder_identifier, + "recorderSnapshotPath": str(output / "recorder-snapshot.json"), + "productionSourceSHA256": { + str(path.relative_to(repo)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in [ + repo / "Apps/GanchoMac/GlobalShortcuts.swift", + repo / "Packages/GanchoKit/Sources/ClipboardCore/ShortcutConflicts.swift", + ] + }, + "fixtureSourceSHA256": { + path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in [ + sources / "FixtureApp.swift", + sources / "ShortcutMenuUITests.swift", + sources / "RecorderFixtureApp.swift", + sources / "ShortcutRecorderUITests.swift", + sources / "prepare.py", + ] + }, + "scope": "native dependency menu and recorder fixtures", + }, indent=2) + "\n") + + +if __name__ == "__main__": + if len(sys.argv) != 4: + sys.exit("Usage: prepare.py ") + prepare(*(Path(argument).resolve() for argument in sys.argv[1:])) diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 13e22a77..b9fa4f0b 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -89,12 +89,50 @@ initial panel binding; changing those raw names would orphan existing user preferences. Feature controllers register handlers, while Settings and onboarding use the package's recorder UI. -Version 3.1.0 is the current baseline. The 3.0 Swift 6 implementation replaced the +Version 3.0.1 is temporarily held by an exact requirement in `project.yml`. +Version 3.1.0 loses the consumed-event result in its local menu monitors and +replays an already handled event. A signed native fixture sent one non-repeat +four-modifier V key press with a menu open: 3.0.1 delivered one down/one up, +while the final strict 3.1.0 control delivered no down callbacks and two up +callbacks, reposting the same key-up event twice. This proves the dependency's menu +behavior in an isolated app, not the complete Gancho composition or the +lowest supported macOS. Do not relax the exact requirement until the native +consumed-event gate passes with the proposed replacement. + +Run the following with a local Git checkout containing the canonical app-lock +revision and an existing trusted signing identity: + +```bash +KEYBOARDSHORTCUTS_CHECKOUT=/path/to/KeyboardShortcuts \ +SHORTCUT_MENU_SIGN_IDENTITY='Developer ID Application: Your Name (TEAMID)' \ +make test-shortcuts-menu +``` + +The gate +exports that exact revision into a disposable signed fixture, opens a native +menu, and asserts one down/one up callback and no replay for one non-repeat +four-modifier V press. It does not launch Gancho or use its store or preferences. +This interactive gate is separate from package tests and needs a signed +XCUITest runner in an active desktop session. + +The 3.0 Swift 6 implementation replaced the registration engine and renamed `Name(default:)` to `Name(initial:)` without changing Gancho's stored Carbon key-code/modifier representation. Signed UI coverage must prove that an existing shortcut restores and has an active registration without rewriting the maintainer's preference. +The isolated recorder gate is `make test-shortcuts-recorder`, using the same +checkout and signing environment. On macOS 26.6.2, exact 3.0.1 passes four of +five cases: restoration, cancellation/switching, replacement/relaunch, and +conflict rejection/clearing. Re-recording an already registered binding stays +in recording mode; the same case passes with unmodified 3.1.0. Version 3.0.1 +keeps Carbon hot keys registered while pausing callbacks, whereas 3.1.0 +temporarily unregisters them so recording can receive the event. This is an +existing limitation of the version already selected by the base branch, not +complete recorder acceptance. Retain the failing assertion and require both +native gates before accepting a future replacement; do not trade the confirmed +menu replay regression for a passing recorder case. + Treat every future major update as its own PR. Review the complete upstream API and registration-lifecycle delta, update `project.yml`, refresh the app lock with `make resolve-dependencies`, update `KEYBOARDSHORTCUTS` in @@ -111,8 +149,11 @@ signed direct-download DMG must build with re-signed helpers and pass ## October 2026 maintenance review -The adopted stable releases match the October 5 canary. This is a dependency -update, not a GRDB fork rebase or an application schema migration. +SQLCipher and Sparkle adopt the stable releases reported by the October 5 +canary. KeyboardShortcuts 3.1.0 is deferred after native validation; its +intentional hold does not suppress the canary's newer-release warning. This +maintenance changes dependencies without rebasing the GRDB fork or changing +the application schema. - **SQLCipher.swift 4.17.0 → 4.19.0.** The existing GRDB fork requirement (`from: "4.17.0"`) already admits this version. The annotated release tag @@ -125,13 +166,14 @@ update, not a GRDB fork rebase or an application schema migration. paths do not establish exploitability in Gancho; the update still brings upstream error-handling and migration fixes. The encryption migration test now covers an apostrophe in the directory name and reopening after export. -- **KeyboardShortcuts 3.0.1 → 3.1.0.** The existing app requirement already - admits it. The annotated tag peels to - `772133d9dbe800fdac0473226822994c5c162c58`. Swift 6.2 and macOS 10.15 remain - compatible with the supported build toolchain and deployment floor. +- **KeyboardShortcuts remains at 3.0.1.** Its annotated tag peels to + `49c3fc04ea827f816df67843bfcc57286b47ff06`. The app requirement is exact so + deliberate dependency resolution cannot silently restore the rejected + 3.1.0 release (`772133d9dbe800fdac0473226822994c5c162c58`). [The upstream delta](https://github.com/sindresorhus/KeyboardShortcuts/compare/3.0.1...3.1.0) changes menu tracking, synthesized Fn handling, and recorder pause/resume; - it does not require changing Gancho's stable names or stored key codes. + it does not require changing Gancho's stable names or stored key codes, but + native validation found the consumed-event regression described above. - **Sparkle 2.9.6 → 2.10.0.** [The release](https://github.com/sparkle-project/Sparkle/releases/tag/2.10.0) raises its macOS floor to 12, below Gancho's 15.4, and fixes update progress, @@ -144,8 +186,9 @@ update, not a GRDB fork rebase or an application schema migration. ### Verification and merge gates The lock revisions above were checked against the upstream annotated tags and -immutable commit manifests; they were not regenerated locally with Xcode. -Manifest requirements and origin hashes are unchanged. CI must consume both +immutable commit manifests. The exact KeyboardShortcuts hold requires +regenerating the app lock and reviewing its origin hash; shared package pins +must remain unchanged. CI must consume both locks with automatic resolution disabled and pass dependency coherence, formatting, lint, package encryption/raw-key/migration/durability tests, StoreKit tests, and both app builds. A local fixture test is not evidence that diff --git a/project.yml b/project.yml index eb9f2e49..bcd84024 100644 --- a/project.yml +++ b/project.yml @@ -56,7 +56,8 @@ packages: # Global hotkey recording + handling (MIT-licensed). KeyboardShortcuts: url: https://github.com/sindresorhus/KeyboardShortcuts - from: 3.0.1 + # Hold until native menu tracking preserves consumed events without replay. + exactVersion: 3.0.1 targets: Gancho: diff --git a/scripts/test-keyboard-shortcuts-menu.sh b/scripts/test-keyboard-shortcuts-menu.sh new file mode 100755 index 00000000..ee87df07 --- /dev/null +++ b/scripts/test-keyboard-shortcuts-menu.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Run a selected native dependency acceptance gate in a UUID fixture app. +set -euo pipefail + +shortcut_repo="$(cd "$(dirname "$0")/.." && pwd -P)" +: "${KEYBOARDSHORTCUTS_CHECKOUT:?Set to a local Git repository containing the canonical locked revision}" +: "${SHORTCUT_MENU_SIGN_IDENTITY:?Set to an existing trusted code signing identity}" +command -v xcodegen >/dev/null +command -v python3 >/dev/null + +case "${SHORTCUT_ACCEPTANCE_SCOPE:-menu}" in +menu) + shortcut_scheme=ShortcutMenuFixture + shortcut_testing=ShortcutMenuUITests/ShortcutMenuUITests/testConsumedMenuShortcutIsDeliveredOnceWithoutRequeue + shortcut_result=native-menu + shortcut_expected_tests=1 + ;; +recorder) + shortcut_scheme=ShortcutRecorderFixture + shortcut_testing=ShortcutRecorderUITests + shortcut_result=native-recorder + shortcut_expected_tests=5 + ;; +*) + printf 'SHORTCUT_ACCEPTANCE_SCOPE must be menu or recorder\n' >&2 + exit 2 + ;; +esac + +if [[ -z "${DEVELOPER_DIR:-}" && "$(xcode-select -p)" == /Library/Developer/CommandLineTools && + -d /Applications/Xcode.app ]]; then + export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer +fi + +# Preserve the tiny manifest/traces and the result bundle for review. No shared +# project, installed app or personal preferences are modified. +shortcut_output="$(mktemp -d "${TMPDIR:-/tmp}/gancho-shortcut-menu.XXXXXX")" +printf 'Native shortcut fixture evidence: %s\n' "$shortcut_output" +python3 "$shortcut_repo/Tests/KeyboardShortcutsAcceptance/prepare.py" \ + "$shortcut_repo" "$shortcut_output" "$KEYBOARDSHORTCUTS_CHECKOUT" +xcodegen generate --spec "$shortcut_output/project.json" --project "$shortcut_output" + +read_manifest() { + python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' \ + "$shortcut_output/manifest.json" "$1" +} + +TEST_RUNNER_SHORTCUT_MENU_VERSION="$(read_manifest version)" +TEST_RUNNER_SHORTCUT_MENU_REVISION="$(read_manifest revision)" +TEST_RUNNER_SHORTCUT_MENU_BUNDLE_ID="$(read_manifest bundleIdentifier)" +TEST_RUNNER_SHORTCUT_MENU_SNAPSHOT="$(read_manifest snapshotPath)" +TEST_RUNNER_SHORTCUT_RECORDER_BUNDLE_ID="$(read_manifest recorderBundleIdentifier)" +TEST_RUNNER_SHORTCUT_RECORDER_SNAPSHOT="$(read_manifest recorderSnapshotPath)" +export TEST_RUNNER_SHORTCUT_MENU_VERSION TEST_RUNNER_SHORTCUT_MENU_REVISION +export TEST_RUNNER_SHORTCUT_MENU_BUNDLE_ID TEST_RUNNER_SHORTCUT_MENU_SNAPSHOT +export TEST_RUNNER_SHORTCUT_RECORDER_BUNDLE_ID TEST_RUNNER_SHORTCUT_RECORDER_SNAPSHOT + +xcodebuild -project "$shortcut_output/ShortcutMenuFixture.xcodeproj" \ + -scheme "$shortcut_scheme" -configuration Debug -destination 'platform=macOS' \ + -derivedDataPath "$shortcut_output/DerivedData" -disableAutomaticPackageResolution \ + -parallel-testing-enabled NO -maximum-concurrent-test-device-destinations 1 \ + -only-testing:"$shortcut_testing" \ + -resultBundlePath "$shortcut_output/$shortcut_result.xcresult" test \ + 2>&1 | tee "$shortcut_output/$shortcut_result.log" + +# A successful runner command alone can hide an empty or skipped test selection. +xcrun xcresulttool get test-results summary \ + --path "$shortcut_output/$shortcut_result.xcresult" \ + >"$shortcut_output/$shortcut_result-summary.json" +python3 - "$shortcut_output/$shortcut_result-summary.json" "$shortcut_expected_tests" <<'PY' +import json +import sys + +with open(sys.argv[1]) as handle: + summary = json.load(handle) +expected = int(sys.argv[2]) +if not ( + summary.get("result") == "Passed" + and summary.get("totalTestCount") == expected + and summary.get("passedTests") == expected + and summary.get("failedTests") == 0 + and summary.get("skippedTests") == 0 + and summary.get("expectedFailures") == 0 +): + sys.exit(f"Native acceptance requires {expected} passing tests without skips or expected failures") +print(f"PASS: {expected} native acceptance tests, zero skips") +PY diff --git a/scripts/upstream-pins.env b/scripts/upstream-pins.env index 0ffc90dd..9ba70dd7 100644 --- a/scripts/upstream-pins.env +++ b/scripts/upstream-pins.env @@ -14,4 +14,5 @@ GRDB=7.11.1 SQLCIPHER=4.19.0 SPARKLE=2.10.0 SAUCE=2.5.2 -KEYBOARDSHORTCUTS=3.1.0 +# Temporary hold: newer releases must pass the native consumed-event gate. +KEYBOARDSHORTCUTS=3.0.1