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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..9ce0d43c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,100 @@ +# 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-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - 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: | + 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 \ + -skipPackagePluginValidation \ + -parallel-testing-enabled NO \ + 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 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() + uses: actions/upload-artifact@v4 + with: + name: TestResults + path: TestResults.xcresult + if-no-files-found: ignore diff --git a/IOCNotesUnitTests/MarkdownTextStorageTests.swift b/IOCNotesUnitTests/MarkdownTextStorageTests.swift index 05dcfb7d..108c3878 100644 --- a/IOCNotesUnitTests/MarkdownTextStorageTests.swift +++ b/IOCNotesUnitTests/MarkdownTextStorageTests.swift @@ -13,14 +13,17 @@ struct MarkdownTextStorageTests { private func applyText(_ text: String) -> NSAttributedString { let textStorage = MarkdownTextStorage() - 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) - textStorage.edited([.editedCharacters, .editedAttributes], range: range, changeInLength: 0) - textStorage.processEditing() - + + // Drive the storage the way a UITextView does: insert the text through + // `replaceCharacters` inside a begin/end editing group. This sets a valid + // `editedRange` and lets `processEditing` run exactly as it does in the + // app. Calling `edited`/`processEditing` by hand instead leaves the + // edited range in the `{NSNotFound, …}` sentinel state, which crashes + // NSTextStorage's attribute fixing. + textStorage.beginEditing() + textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: text) + textStorage.endEditing() + return NSAttributedString(attributedString: textStorage) } @@ -48,6 +51,14 @@ struct MarkdownTextStorageTests { guard location < attributedString.length else { return nil } return attributedString.attribute(.strikethroughStyle, at: location, effectiveRange: nil) as? Int } + + private func isBodyMonospacedFont(_ font: UIFont?) -> Bool { + guard let font else { return false } + let bodyFont = UIFont.preferredFont(forTextStyle: .body) + let expectedFont = UIFont(style: .body, design: .monospaced) + ?? UIFont.monospacedSystemFont(ofSize: bodyFont.pointSize, weight: .regular) + return font.fontName == expectedFont.fontName && font.pointSize == expectedFont.pointSize + } // MARK: - Header Tests @@ -65,14 +76,13 @@ struct MarkdownTextStorageTests { #expect(font != nil, "Font should not be nil for: \(text)") - // Check that font size matches expected style (larger than body) + // Check that the configured text style is applied. let expectedFont = UIFont.preferredFont(forTextStyle: expectedStyle) - let bodyFont = UIFont.preferredFont(forTextStyle: .body) #expect(font!.pointSize >= expectedFont.pointSize * 0.9, "Header font size should be appropriate for: \(text)") - #expect(font!.pointSize > bodyFont.pointSize, - "Header font should be larger than body font for: \(text)") + #expect(font!.pointSize == expectedFont.pointSize, + "Header font should match its configured text style for: \(text)") } @Test("Header hashtag fading") @@ -120,10 +130,7 @@ struct MarkdownTextStorageTests { let codeHeaderRange = nsText.range(of: "# This is not a header") if codeHeaderRange.location != NSNotFound { let font = getFontAt(codeHeaderRange.location, in: result) - #expect(font?.familyName.contains("Menlo") == true || - font?.familyName.contains("Monaco") == true || - font?.familyName.contains("Courier") == true || - font?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) == true, + #expect(isBodyMonospacedFont(font), "Header inside code block should use monospaced font") } } @@ -284,10 +291,7 @@ struct MarkdownTextStorageTests { let font = getFontAt(startIndex, in: result) let backgroundColor = getBackgroundColorAt(startIndex, in: result) - #expect(font?.familyName.contains("Menlo") == true || - font?.familyName.contains("Monaco") == true || - font?.familyName.contains("Courier") == true || - font?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) == true, + #expect(isBodyMonospacedFont(font), "Inline code should use monospaced font") #expect(backgroundColor != nil, "Inline code should have background color") } @@ -323,10 +327,7 @@ struct MarkdownTextStorageTests { let font = getFontAt(currentIndex, in: result) let backgroundColor = getBackgroundColorAt(currentIndex, in: result) - #expect(font?.familyName.contains("Menlo") == true || - font?.familyName.contains("Monaco") == true || - font?.familyName.contains("Courier") == true || - font?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) == true, + #expect(isBodyMonospacedFont(font), "Code block should use monospaced font for line: \(line)") #expect(backgroundColor != nil, "Code block should have background color") } @@ -361,10 +362,7 @@ struct MarkdownTextStorageTests { let font = getFontAt(notBoldRange.location, in: result) // Should be monospaced - #expect(font?.familyName.contains("Menlo") == true || - font?.familyName.contains("Monaco") == true || - font?.familyName.contains("Courier") == true || - font?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) == true, + #expect(isBodyMonospacedFont(font), "Code should be monospaced") // Should not have special formatting @@ -376,10 +374,7 @@ struct MarkdownTextStorageTests { let font = getFontAt(notHeaderRange.location, in: result) // Should be monospaced, not larger header font - #expect(font?.familyName.contains("Menlo") == true || - font?.familyName.contains("Monaco") == true || - font?.familyName.contains("Courier") == true || - font?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) == true, + #expect(isBodyMonospacedFont(font), "Code should be monospaced, not header styled") } } @@ -495,10 +490,7 @@ struct MarkdownTextStorageTests { let codeTextRange = nsText.range(of: "**Not bold**") if codeTextRange.location != NSNotFound { let font = getFontAt(codeTextRange.location, in: result) - #expect(font?.familyName.contains("Menlo") == true || - font?.familyName.contains("Monaco") == true || - font?.familyName.contains("Courier") == true || - font?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) == true, + #expect(isBodyMonospacedFont(font), "Code block should use monospaced font") #expect(!font!.fontDescriptor.symbolicTraits.contains(.traitBold), "Text in code block should not be bold") diff --git a/IOCNotesUnitTests/NotesTests.swift b/IOCNotesUnitTests/NotesTests.swift index 1507e779..e2f77771 100644 --- a/IOCNotesUnitTests/NotesTests.swift +++ b/IOCNotesUnitTests/NotesTests.swift @@ -2,11 +2,18 @@ // SPDX-FileCopyrightText: 2019 Peter Hedlund // SPDX-License-Identifier: GPL-3.0-or-later -import Testing import Dispatch +import Foundation +import Testing @testable import iOCNotes -@Suite("Notes Management Tests") +@Suite( + "Notes Management Tests", + .enabled( + if: ProcessInfo.processInfo.environment["NOTES_INTEGRATION_TESTS"] == "1", + "Requires the local Nextcloud test server" + ) +) class NotesTests { var originalServer: String = "" var originalUser: String = "" @@ -20,8 +27,8 @@ class NotesTests { // Set test values KeychainHelper.server = "http://localhost:8080" - KeychainHelper.username = "cloudnotes" - KeychainHelper.password = "cloudnotes" + KeychainHelper.username = "nonotes" + KeychainHelper.password = "password" // Clear database Note.reset() diff --git a/Server/README.md b/Server/README.md index 65aad501..6dbec9b1 100644 --- a/Server/README.md +++ b/Server/README.md @@ -36,6 +36,13 @@ NEXTCLOUD_TRUSTED_DOMAINS='192.168.178.*' ./Server/Run.sh You only need this one as long as you do not delete the container again. Until then you can start or stop it with Docker Desktop. +The network-backed tests are opt-in so regular unit-test and CI runs do not +depend on this server. After provisioning it, include those tests with: + +```sh +NOTES_INTEGRATION_TESTS=1 xcodebuild test -scheme iOCNotes -destination 'platform=iOS Simulator,name=iPhone 16' +``` + To quickly get rid of the container and delete all its data: ```sh @@ -45,4 +52,4 @@ docker rm --force --volumes nextcloud-notes-test-server ### A Lot Of Notes In `Notes/A Lot Of/` there is a shell script to quickly generate about a thousand random markdown files. -To avoid bloating the repository with random test data, this has been excluded intentionally and needs to be generated on demand. \ No newline at end of file +To avoid bloating the repository with random test data, this has been excluded intentionally and needs to be generated on demand. 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"> - - - -