From b7671a70406e80e228e46fb982349b62d5855336 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Thu, 9 Jul 2026 13:26:51 +0000 Subject: [PATCH 1/7] ci: run unit tests on GitHub Actions Add a Tests workflow that runs the IOCNotesUnitTests suite via `xcodebuild test` on a macOS runner for pushes and pull requests to main. Also remove the stale `iOCNotesTests` testable from the shared iOCNotes scheme; that target no longer exists in the project and would break `xcodebuild test`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VuMUmwcBag6qfvo3B4zzKN Signed-off-by: Julius Knorr --- .github/workflows/tests.yml | 76 +++++++++++++++++++ .../xcshareddata/xcschemes/iOCNotes.xcscheme | 10 --- 2 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..24f3a81e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Nextcloud GmbH +# SPDX-License-Identifier: GPL-3.0-or-later + +name: Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select --switch /Applications/Xcode_26.app + + - name: Show versions + run: | + xcodebuild -version + swift --version + + - name: Resolve Swift package dependencies + run: | + xcodebuild -resolvePackageDependencies \ + -project iOCNotes.xcodeproj \ + -scheme iOCNotes + + - name: Pick an iOS simulator + id: sim + run: | + # Choose the newest available iPhone simulator so we don't hard-code a + # device name that may change between Xcode releases. + cat > /tmp/pick_sim.py <<'PY' + import json, sys + devices = json.load(sys.stdin)["devices"] + cands = [d for rt, ds in devices.items() if "iOS" in rt + for d in ds if d.get("isAvailable") and "iPhone" in d["name"]] + print(cands[-1]["udid"] if cands else "") + PY + UDID=$(xcrun simctl list devices available --json | python3 /tmp/pick_sim.py) + if [ -z "$UDID" ]; then + echo "No iOS simulator available" >&2 + xcrun simctl list devices available + exit 1 + fi + echo "Using simulator $UDID" + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + + - name: Run unit tests + run: | + xcodebuild test \ + -project iOCNotes.xcodeproj \ + -scheme iOCNotes \ + -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ + -resultBundlePath TestResults.xcresult \ + CODE_SIGNING_ALLOWED=NO + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: TestResults + path: TestResults.xcresult + if-no-files-found: ignore diff --git a/iOCNotes.xcodeproj/xcshareddata/xcschemes/iOCNotes.xcscheme b/iOCNotes.xcodeproj/xcshareddata/xcschemes/iOCNotes.xcscheme index ec0e6e8f..4a29d5e3 100644 --- a/iOCNotes.xcodeproj/xcshareddata/xcschemes/iOCNotes.xcscheme +++ b/iOCNotes.xcodeproj/xcshareddata/xcschemes/iOCNotes.xcscheme @@ -29,16 +29,6 @@ shouldUseLaunchSchemeArgsEnv = "YES" codeCoverageEnabled = "YES"> - - - - From dfb35d73d424de40950ac75994aaabf507f0f82f Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Thu, 9 Jul 2026 13:26:51 +0000 Subject: [PATCH 2/7] ci: enable SwiftLint workflow and add Dependabot The SwiftLint workflow was named `.swiftlint.yml`; GitHub Actions ignores workflow files whose names begin with a dot, so the lint check never ran. Rename it to `swiftlint.yml` so it is discovered and executed. Add `.github/dependabot.yml` to keep Swift Package Manager dependencies and the GitHub Actions used in our workflows up to date. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VuMUmwcBag6qfvo3B4zzKN Signed-off-by: Julius Knorr --- .github/dependabot.yml | 25 +++++++++++++++++++ .../{.swiftlint.yml => swiftlint.yml} | 0 2 files changed, 25 insertions(+) create mode 100644 .github/dependabot.yml rename .github/workflows/{.swiftlint.yml => swiftlint.yml} (100%) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0d7a7bfa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Nextcloud GmbH +# SPDX-License-Identifier: GPL-3.0-or-later + +version: 2 +updates: + # Swift Package Manager dependencies (Alamofire, NextcloudKit, swift-markdown, …) + - package-ecosystem: "swift" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + commit-message: + prefix: "fix(deps)" + + # Keep the GitHub Actions used by our workflows up to date + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + commit-message: + prefix: "ci" diff --git a/.github/workflows/.swiftlint.yml b/.github/workflows/swiftlint.yml similarity index 100% rename from .github/workflows/.swiftlint.yml rename to .github/workflows/swiftlint.yml From 4def52163da93e284b2bfbc7522f9b4a41c37f8b Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Thu, 9 Jul 2026 13:31:06 +0000 Subject: [PATCH 3/7] ci: use macos-15 runner and select Xcode dynamically The test job hard-coded /Applications/Xcode_26.app, which does not exist on the runner and failed with "invalid developer directory". Switch to the macos-15 runner and select the newest installed Xcode dynamically so the job no longer depends on a specific Xcode path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VuMUmwcBag6qfvo3B4zzKN Signed-off-by: Julius Knorr --- .github/workflows/tests.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 24f3a81e..56431276 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,14 +17,20 @@ concurrency: jobs: test: - runs-on: macos-26 + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode - run: sudo xcode-select --switch /Applications/Xcode_26.app + - name: Select latest installed Xcode + run: | + # Pick the newest Xcode present on the runner instead of hard-coding a + # path, so the job keeps working as runner images change. + LATEST=$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -1) + LATEST=${LATEST:-/Applications/Xcode.app} + echo "Selecting $LATEST" + sudo xcode-select --switch "$LATEST" - name: Show versions run: | From d40d1572e08333c4bc8a6342b9c0c419a76b0d11 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Thu, 9 Jul 2026 13:36:15 +0000 Subject: [PATCH 4/7] ci: skip package plugin validation when running tests The build embeds the SwiftLint build tool plugin, which requires interactive trust and fails in headless CI with "Plugin SwiftLintBuildToolPlugin ... must be enabled before it can be used". Pass -skipPackagePluginValidation to xcodebuild so package plugins are trusted automatically on the runner. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VuMUmwcBag6qfvo3B4zzKN Signed-off-by: Julius Knorr --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 56431276..aa4b34aa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,6 +71,7 @@ jobs: -scheme iOCNotes \ -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ -resultBundlePath TestResults.xcresult \ + -skipPackagePluginValidation \ CODE_SIGNING_ALLOWED=NO - name: Upload test results From 1193e4dbb48489ce5529263d425316b221e96664 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Thu, 9 Jul 2026 19:52:32 +0000 Subject: [PATCH 5/7] ci: surface crash details when tests fail All tests currently fail instantly with no assertion output, which points to the host application failing to launch in the simulator. Add a failure-only step that extracts the test-results summary and any crash logs from the .xcresult bundle so the real cause is visible in the build log. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VuMUmwcBag6qfvo3B4zzKN Signed-off-by: Julius Knorr --- .github/workflows/tests.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aa4b34aa..fe53a40c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,6 +74,18 @@ jobs: -skipPackagePluginValidation \ CODE_SIGNING_ALLOWED=NO + - name: Show failure details + if: failure() + run: | + # Surface the actual test/crash reason from the result bundle, since a + # host-app launch failure prints nothing useful to the build log. + echo "===== test results summary =====" + xcrun xcresulttool get test-results summary \ + --path TestResults.xcresult || true + echo "===== crash / diagnostic logs =====" + find TestResults.xcresult -name "*.crash" -o -name "*.ips" 2>/dev/null \ + | while read -r f; do echo "--- $f ---"; cat "$f" || true; done + - name: Upload test results if: always() uses: actions/upload-artifact@v4 From 1227378addd2bcba08642e9d3f1425266c3fa65b Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Thu, 9 Jul 2026 20:19:44 +0000 Subject: [PATCH 6/7] ci: run tests serially to stabilize and surface the crash The whole test host process traps (signal trap) originating in the markdown formatting path, and the target's scheme marks it parallelizable, so every test then reports "crashed with signal trap". Disable parallel testing so the run is deterministic and xcodebuild prints the actual fatal-error line, which also rules in/out a concurrency cause. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VuMUmwcBag6qfvo3B4zzKN Signed-off-by: Julius Knorr --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fe53a40c..bb9fb959 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -72,6 +72,7 @@ jobs: -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ -resultBundlePath TestResults.xcresult \ -skipPackagePluginValidation \ + -parallel-testing-enabled NO \ CODE_SIGNING_ALLOWED=NO - name: Show failure details From 5ad9b35d1536a9337883197c4e8ccf26b23e5f54 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Thu, 9 Jul 2026 20:46:16 +0000 Subject: [PATCH 7/7] fix(editor): harden markdown range conversion against out-of-bounds indices MarkdownTextStorage.processEditing crashed the whole test suite with "String index is out of bounds" (signal trap) via String.nsRange(from:), which force-unwrapped samePosition(in:) and ran utf16.distance on indices that may not belong to the current backing string. - nsRange(from:): validate the range lies within the string and return an empty NSNotFound range instead of trapping on foreign/stale indices. - processEditing: clamp the edited location to the string length and intersect the formatting range with the full range, skipping formatting when the line conversion fails. - MarkdownTextStorageTests.applyText: use the UTF-16 length rather than the grapheme count, which is what NSTextStorage ranges are measured in. Also export crash backtraces from the .xcresult on failure so future crashes are diagnosable directly from the CI log. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VuMUmwcBag6qfvo3B4zzKN Signed-off-by: Julius Knorr --- .github/workflows/tests.yml | 10 +++++++--- Editor/Extensions.swift | 16 ++++++++++++---- Editor/MarkdownTextStorage.swift | 14 +++++++++++--- IOCNotesUnitTests/MarkdownTextStorageTests.swift | 6 ++++-- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bb9fb959..9ce0d43c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -83,9 +83,13 @@ jobs: echo "===== test results summary =====" xcrun xcresulttool get test-results summary \ --path TestResults.xcresult || true - echo "===== crash / diagnostic logs =====" - find TestResults.xcresult -name "*.crash" -o -name "*.ips" 2>/dev/null \ - | while read -r f; do echo "--- $f ---"; cat "$f" || true; done + echo "===== crash backtraces =====" + xcrun xcresulttool export diagnostics \ + --path TestResults.xcresult \ + --output-path _diagnostics 2>/dev/null || true + find _diagnostics TestResults.xcresult \ + \( -name "*.crash" -o -name "*.ips" \) 2>/dev/null \ + | while read -r f; do echo "--- $f ---"; sed -n '1,150p' "$f" || true; done - name: Upload test results if: always() diff --git a/Editor/Extensions.swift b/Editor/Extensions.swift index 7a9decd0..94056b20 100644 --- a/Editor/Extensions.swift +++ b/Editor/Extensions.swift @@ -47,10 +47,18 @@ extension String { /// /// - returns: The equivalent NSRange. func nsRange(from range: Range) -> NSRange { - let from = range.lowerBound.samePosition(in: utf16) - let to = range.upperBound.samePosition(in: utf16) - return NSRange(location: utf16.distance(from: utf16.startIndex, to: from!), - length: utf16.distance(from: from!, to: to!)) + // Guard against indices that do not belong to this string (for example a + // range computed against a longer, stale version of the text). Feeding + // such indices to `utf16.distance` traps with "String index is out of + // bounds", so fall back to an empty range instead of crashing. + guard range.lowerBound >= startIndex, + range.upperBound <= endIndex, + let from = range.lowerBound.samePosition(in: utf16), + let to = range.upperBound.samePosition(in: utf16) else { + return NSRange(location: NSNotFound, length: 0) + } + return NSRange(location: utf16.distance(from: utf16.startIndex, to: from), + length: utf16.distance(from: from, to: to)) } /// Converts a String to a NSRegularExpression. diff --git a/Editor/MarkdownTextStorage.swift b/Editor/MarkdownTextStorage.swift index 8f032f1d..b54e5251 100644 --- a/Editor/MarkdownTextStorage.swift +++ b/Editor/MarkdownTextStorage.swift @@ -60,10 +60,18 @@ public class MarkdownTextStorage: NSTextStorage { override public func processEditing() { let backingString = backingStore.string - if let nsRange = backingString.range(from: NSMakeRange(NSMaxRange(editedRange), 0)) { + let fullRange = NSRange(location: 0, length: (backingString as NSString).length) + // The edited range can point just past the end of the string (or be stale + // relative to the backing store), so clamp before converting to avoid + // out-of-bounds string-index math. + let location = min(NSMaxRange(editedRange), fullRange.length) + if let nsRange = backingString.range(from: NSMakeRange(location, 0)) { let indexRange = backingString.lineRange(for: nsRange) - let extendedRange: NSRange = NSUnionRange(editedRange, backingString.nsRange(from: indexRange)) - applyMarkdownFormatting(extendedRange) + let lineNSRange = backingString.nsRange(from: indexRange) + if lineNSRange.location != NSNotFound { + let extendedRange = NSIntersectionRange(NSUnionRange(editedRange, lineNSRange), fullRange) + applyMarkdownFormatting(extendedRange) + } } super.processEditing() } diff --git a/IOCNotesUnitTests/MarkdownTextStorageTests.swift b/IOCNotesUnitTests/MarkdownTextStorageTests.swift index 05dcfb7d..f6b70b7e 100644 --- a/IOCNotesUnitTests/MarkdownTextStorageTests.swift +++ b/IOCNotesUnitTests/MarkdownTextStorageTests.swift @@ -16,8 +16,10 @@ struct MarkdownTextStorageTests { let attributedString = NSAttributedString(string: text) textStorage.setAttributedString(attributedString) - // Manually trigger formatting since we're not in a text view - let range = NSRange(location: 0, length: text.count) + // Manually trigger formatting since we're not in a text view. + // Use the UTF-16 length (not the grapheme count) since that is what + // NSTextStorage/NSAttributedString ranges are measured in. + let range = NSRange(location: 0, length: (text as NSString).length) textStorage.edited([.editedCharacters, .editedAttributes], range: range, changeInLength: 0) textStorage.processEditing()