mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Resurrect dead Noise vector tests; add CI perf floors; make tests hermetic
Package.swift's .process("Noise") resource claim silently excluded all
of bitchatTests/Noise/ from compilation since Oct 2025 - including a
complete official-vector runner (cacophony + snow XX transcripts,
transport messages, handshake hash, byte-identical to upstream).
Narrowing the resource to the JSON file and loading via Bundle.module
brings 51 Noise tests back to life, with a guard asserting each
vector's protocol name matches the app's.
CI gains a performance floor gate: perf-floors.json carries deliberately
generous floors (~25% of measured throughput) that catch algorithmic
regressions without flaking on runner variance; PERF lines reach the
gate via an O_APPEND side-channel file since swift test --parallel
swallows passing tests' stdout.
Tests are now hermetic: FavoritesPersistenceService uses an in-memory
keychain under test (fixes the securityd hang that blocked pipeline
benchmarks locally) and read-receipt persistence uses a wiped scratch
UserDefaults suite instead of .standard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
+8
-2
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// TestEnvironment.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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
|
||||
}()
|
||||
}
|
||||
@@ -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<String> = [] { // 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+108
@@ -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 <test-output-file> [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 <test-output-file> [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
|
||||
Reference in New Issue
Block a user