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:
jack
2026-06-11 15:53:34 +02:00
co-authored by Claude Fable 5
parent 8a867a17a1
commit 22be3d6392
9 changed files with 285 additions and 12 deletions
+14
View File
@@ -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
View File
@@ -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()
+25
View File
@@ -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
}()
}
+24 -4
View File
@@ -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
+25 -1
View File
@@ -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)
+41
View File
@@ -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
}
}
+108
View File
@@ -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