diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index c9265e89..4a3ecdb4 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -39,8 +39,22 @@ jobs: ${{ runner.os }}-${{ matrix.name }}- - name: Run Tests + # BITCHAT_PERF_LOG captures the PERF[...] lines that + # PerformanceBaselineTests reports (swift test --parallel swallows + # stdout of passing tests, so the floor gate reads this file instead). + env: + BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log run: swift test --parallel --quiet --enable-code-coverage --package-path ${{ matrix.path }} + # Order-of-magnitude performance regression gate (app tests only — the + # package matrix entries write no PERF lines and the gate would skip + # anyway). Floors are deliberately generous (~25% of healthy local + # throughput, see bitchatTests/Performance/perf-floors.json) so this + # catches algorithmic regressions, never runner variance. + - name: Performance floor gate + if: matrix.name == 'app' + run: ./scripts/check-perf-floors.sh perf-output.log + # Informational only: surfaces per-file and total line coverage in the # job log so coverage trends are visible on every PR. No thresholds — # this must never be the reason a build goes red. diff --git a/Package.swift b/Package.swift index d7bc2cb6..3f6e6b8f 100644 --- a/Package.swift +++ b/Package.swift @@ -53,11 +53,17 @@ let package = Package( path: "bitchatTests", exclude: [ "Info.plist", - "README.md" + "README.md", + // CI perf gate data (read by scripts/check-perf-floors.sh), + // not a test resource. + "Performance/perf-floors.json" ], resources: [ .process("Localization"), - .process("Noise") + // Only the vector fixture: declaring the whole "Noise" + // directory would claim its .swift test files as resources + // and silently drop them from compilation. + .process("Noise/NoiseTestVectors.json") ] ) ] diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift index f6785c3a..6e11c00f 100644 --- a/bitchat/Services/FavoritesPersistenceService.swift +++ b/bitchat/Services/FavoritesPersistenceService.swift @@ -34,7 +34,17 @@ final class FavoritesPersistenceService: ObservableObject { static let shared = FavoritesPersistenceService() - init(keychain: KeychainManagerProtocol = KeychainManager()) { + /// Default keychain for the `shared` singleton. Under test this is an + /// in-memory keychain so touching `shared` never blocks on securityd + /// (`SecItemCopyMatching` can hang in test environments) and never reads + /// or writes the developer's real keychain. Production behavior is + /// unchanged. Tests that need their own instance keep injecting a mock + /// via `init(keychain:)`. + private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol { + TestEnvironment.isRunningTests ? PreviewKeychainManager() : KeychainManager() + } + + init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) { self.keychain = keychain loadFavorites() diff --git a/bitchat/Services/TestEnvironment.swift b/bitchat/Services/TestEnvironment.swift new file mode 100644 index 00000000..86854a6c --- /dev/null +++ b/bitchat/Services/TestEnvironment.swift @@ -0,0 +1,25 @@ +// +// TestEnvironment.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Process-level test-environment detection for singletons that must swap a +/// real OS-backed dependency (keychain, persistent defaults, notifications) +/// for an in-memory one under test. Mirrors the detection already used by +/// `NotificationService` and `LocationStateManager`. +enum TestEnvironment { + /// True when running under XCTest / Swift Testing or in CI. + static let isRunningTests: Bool = { + let env = ProcessInfo.processInfo.environment + return NSClassFromString("XCTestCase") != nil || + env["XCTestConfigurationFilePath"] != nil || + env["XCTestBundlePath"] != nil || + env["GITHUB_ACTIONS"] != nil || + env["CI"] != nil + }() +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 2cb65804..93f98484 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -406,6 +406,23 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Single-writer: mutate only via `setPublicBatching(_:)` below. @Published private(set) var isBatchingPublic: Bool = false + // Backing store for `sentReadReceipts` persistence. `.standard` in + // production; injectable so tests can use a scratch suite that does not + // leak state between runs. + let readReceiptsDefaults: UserDefaults + + /// Default read-receipt persistence store. Production uses `.standard`. + /// Under test, a dedicated scratch suite is used instead — wiped at first + /// use per process — so back-to-back local test runs never see each + /// other's persisted receipts (and tests never pollute `.standard`). + static let defaultReadReceiptsDefaults: UserDefaults = { + guard TestEnvironment.isRunningTests else { return .standard } + let suiteName = "chat.bitchat.tests.readReceipts" + guard let scratch = UserDefaults(suiteName: suiteName) else { return .standard } + scratch.removePersistentDomain(forName: suiteName) + return scratch + }() + // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed var sentReadReceipts: Set = [] { // messageID set @@ -413,9 +430,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Only persist if there are changes guard oldValue != sentReadReceipts else { return } - // Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read) + // Persist whenever it changes (no manual synchronize/verify re-read) if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) { - UserDefaults.standard.set(data, forKey: "sentReadReceipts") + readReceiptsDefaults.set(data, forKey: "sentReadReceipts") } else { SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session) } @@ -763,7 +780,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele conversations: ConversationStore? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, - locationManager: LocationChannelManager = .shared + locationManager: LocationChannelManager = .shared, + readReceiptsDefaults: UserDefaults? = nil ) { let conversations = conversations ?? ConversationStore() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() @@ -790,7 +808,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele self.autocompleteService = services.autocompleteService self.deduplicationService = services.deduplicationService self.publicMessagePipeline = services.publicMessagePipeline - self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts() + let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults + self.readReceiptsDefaults = readReceiptsDefaults + self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts(userDefaults: readReceiptsDefaults) // Republish on every store change so SwiftUI observers of the // view model refresh. This replaces the UI-update role of the old diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index 6ba8dbfa..9c5f2dc1 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -14,6 +14,18 @@ import BitFoundation // MARK: - Test Vector Support +/// Official Noise test vectors (NoiseTestVectors.json) for +/// `Noise_XX_25519_ChaChaPoly_SHA256` — the exact protocol this app speaks +/// (see `NoiseProtocolName` / `NoisePattern.XX`). Embedded byte-for-byte from +/// the two canonical community vector suites: +/// - cacophony: https://raw.githubusercontent.com/haskell-cryptography/cacophony/master/vectors/cacophony.txt +/// (6 messages: full XX handshake transcript + 3 transport messages, with +/// `handshake_hash`) +/// - snow: https://raw.githubusercontent.com/mcginty/snow/main/tests/vectors/snow.txt +/// (5 messages: full XX handshake transcript + 2 transport messages) +/// Plain XX has no PSKs and no pre-message keys; prologue is part of both +/// vectors and is mixed via `NoiseHandshakeState(prologue:)`. Fixed ephemerals +/// come in through the `predeterminedEphemeralKey` test seam. struct NoiseTestVector: Codable { let protocol_name: String let init_prologue: String @@ -586,9 +598,16 @@ struct NoiseProtocolTests { @Test func noiseTestVectors() throws { // Load test vectors from bundle let testVectors = try loadTestVectors() - + #expect(!testVectors.isEmpty, "No Noise test vectors loaded from fixture") + + // Every embedded vector must target the exact protocol the app uses. + let appProtocolName = NoiseProtocolName(pattern: NoisePattern.XX.patternName).fullName + for (index, testVector) in testVectors.enumerated() { print("Running test vector \(index + 1): \(testVector.protocol_name)") + #expect( + testVector.protocol_name == appProtocolName, + "Vector \(index + 1) targets \(testVector.protocol_name), app speaks \(appProtocolName)") try runTestVector(testVector) } } @@ -612,8 +631,13 @@ struct NoiseProtocolTests { } private func loadTestVectors() throws -> [NoiseTestVector] { - // Try to load from test bundle + // SwiftPM puts processed resources in the module bundle; the Xcode + // test target puts them in the test bundle itself. + #if SWIFT_PACKAGE + let testBundle = Bundle.module + #else let testBundle = Bundle(for: MockKeychain.self) + #endif guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json") else { throw NSError( diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index f8954db5..39d62ac0 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -38,15 +38,40 @@ final class PerformanceBaselineTests: XCTestCase { } /// Reports one human-readable throughput line per benchmark so CI logs - /// are readable without parsing XCTest's measure output. + /// are readable without parsing XCTest's measure output. The same line is + /// appended to the file named by `BITCHAT_PERF_LOG` (if set): under + /// `swift test --parallel` the runner swallows stdout of passing tests, + /// so the CI floor gate (scripts/check-perf-floors.sh) reads the file. private func reportThroughput(_ name: String, samples: [TimeInterval], operations: Int, unit: String) { guard !samples.isEmpty else { return } let avg = samples.reduce(0, +) / Double(samples.count) let opsPerSec = avg > 0 ? Double(operations) / avg : .infinity - print(String( + let line = String( format: "PERF[%@]: %.0f %@/sec (avg %.3f ms per pass of %d, %d passes)", name, opsPerSec, unit, avg * 1000, operations, samples.count - )) + ) + print(line) + Self.appendToPerfLog(line) + } + + private static var perfLogPath: String? { + let path = ProcessInfo.processInfo.environment["BITCHAT_PERF_LOG"] + return (path?.isEmpty ?? true) ? nil : path + } + + /// Appends with `O_APPEND` because `swift test --parallel` may split this + /// class across worker processes that write concurrently. The file is + /// append-only (CI workspaces start fresh); delete it between local runs + /// if you reuse a path. + private static func appendToPerfLog(_ line: String) { + guard let path = perfLogPath else { return } + let fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0o644) + guard fd >= 0 else { return } + defer { close(fd) } + let bytes = Array((line + "\n").utf8) + bytes.withUnsafeBufferPointer { buffer in + _ = write(fd, buffer.baseAddress, buffer.count) + } } // MARK: - 1a. Nostr inbound event handling (fresh events) diff --git a/bitchatTests/Performance/perf-floors.json b/bitchatTests/Performance/perf-floors.json new file mode 100644 index 00000000..41517400 --- /dev/null +++ b/bitchatTests/Performance/perf-floors.json @@ -0,0 +1,41 @@ +{ + "_philosophy": [ + "Floor throughputs for the PERF[...] lines printed by PerformanceBaselineTests.", + "Floors catch algorithmic regressions (O(n) -> O(n^2), accidental sync I/O,", + "quadratic re-scans), NOT tuning noise: each floor is deliberately set at", + "~25% of the throughput measured on a local dev machine (2026-06, Apple", + "Silicon), leaving ~4x headroom for CI runner variance so the gate never", + "flakes on a slow runner while still failing loudly on order-of-magnitude", + "regressions.", + "Raise floors deliberately after intentional performance improvements;", + "lower them only with a written justification in the PR. If a benchmark is", + "renamed or removed, update this file in the same change - the gate fails", + "when a floored benchmark stops reporting.", + "Checked by scripts/check-perf-floors.sh against captured swift-test output." + ], + "_units": "operations per second, matching each benchmark's PERF line", + "_reference_local_numbers_2026_06": { + "nostrInbound.fresh": 2132, + "nostrInbound.duplicate": 2558907, + "bleInbound.roundTripAndDedup": 38063, + "gcs.buildAndDecode": 776, + "delivery.incrementalUpdate": 172615, + "delivery.storeUpdate": 158862, + "formatting.formatMessage": 12261, + "pipeline.privateIngest": 24848, + "pipeline.publicIngest": 13102, + "store.append": 213201 + }, + "floors": { + "nostrInbound.fresh": 530, + "nostrInbound.duplicate": 600000, + "bleInbound.roundTripAndDedup": 9500, + "gcs.buildAndDecode": 190, + "delivery.incrementalUpdate": 43000, + "delivery.storeUpdate": 39000, + "formatting.formatMessage": 3000, + "pipeline.privateIngest": 6000, + "pipeline.publicIngest": 3200, + "store.append": 53000 + } +} diff --git a/scripts/check-perf-floors.sh b/scripts/check-perf-floors.sh new file mode 100755 index 00000000..27d72a9e --- /dev/null +++ b/scripts/check-perf-floors.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# check-perf-floors.sh — order-of-magnitude performance regression gate. +# +# Parses the `PERF[name]: N unit/sec ...` lines that PerformanceBaselineTests +# prints into captured test output and fails if any benchmark's throughput is +# below its floor from bitchatTests/Performance/perf-floors.json. +# +# Floor philosophy (see the floors file): floors sit at ~25% of locally +# measured throughput, so they catch algorithmic regressions (O(n) -> O(n^2)), +# never runner variance. Raise floors deliberately after intentional +# improvements; never tune them to chase noise. +# +# Usage: scripts/check-perf-floors.sh [floors-file] +# +# Skips gracefully (exit 0) when: +# - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or +# - the output contains no PERF lines (e.g. package-only matrix entries). +# +# Fails (exit 1) when: +# - any benchmark reports throughput below its floor, or +# - PERF lines are present but a floored benchmark is missing +# (a silently-dropped benchmark must be an explicit floors-file change). + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 [floors-file]" >&2 + exit 2 +fi + +OUTPUT_FILE="$1" +FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}" + +if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then + echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate." + exit 0 +fi + +if [[ ! -f "$OUTPUT_FILE" ]]; then + echo "perf-floors: output file '$OUTPUT_FILE' not found — skipping gate." >&2 + exit 0 +fi + +if [[ ! -f "$FLOORS_FILE" ]]; then + echo "perf-floors: floors file '$FLOORS_FILE' not found." >&2 + exit 2 +fi + +if ! grep -q 'PERF\[' "$OUTPUT_FILE"; then + echo "perf-floors: no PERF lines in '$OUTPUT_FILE' — skipping gate." + exit 0 +fi + +OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF' +import json +import os +import re +import sys + +output_file = os.environ["OUTPUT_FILE"] +floors_file = os.environ["FLOORS_FILE"] + +with open(floors_file) as f: + floors = json.load(f)["floors"] + +# PERF[delivery.storeUpdate]: 158862 updates/sec (avg 3.147 ms per pass of 500, 10 passes) +pattern = re.compile(r"PERF\[([^\]]+)\]:\s*([0-9]+(?:\.[0-9]+)?)\s*(\S+)/sec") + +measured = {} +with open(output_file, errors="replace") as f: + for line in f: + m = pattern.search(line) + if m: + # Keep the last reported value if a benchmark prints twice. + measured[m.group(1)] = (float(m.group(2)), m.group(3)) + +failures = [] +print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)") +for name in sorted(set(floors) | set(measured)): + floor = floors.get(name) + if name not in measured: + failures.append( + f" MISSING {name}: floored benchmark reported no PERF line " + f"(removed/renamed? update perf-floors.json in the same change)") + continue + value, unit = measured[name] + if floor is None: + print(f" NO-FLOOR {name}: {value:.0f} {unit}/sec (consider adding a floor)") + continue + status = "OK" if value >= floor else "BELOW" + line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})" + print(line) + if value < floor: + failures.append( + f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} " + f"({value / floor * 100:.0f}% of floor)") + +if failures: + print("\nperf-floors: FAILED — order-of-magnitude-class regression suspected:") + print("\n".join(failures)) + print("\nFloors are ~25% of healthy local throughput; falling below one means an") + print("algorithmic regression, not runner noise. If the change is intentional,") + print("update bitchatTests/Performance/perf-floors.json deliberately.") + sys.exit(1) + +print("perf-floors: all benchmarks at or above their floors.") +PYEOF