Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions docs/perf-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
summary: How to run and interpret the iOS typing performance audit commands and outputs.
read_when:
- You need avg/best/worst keypress latency from the composer.
- You are profiling UI-thread work during chat composer typing.
- You are troubleshooting samply vs xctrace profiling on iOS simulator.
---

# iOS Typing Perf Testing

This document describes the current typing performance audit flow for the iOS app, focused on one question:

- Does typing into the chat composer block or stall the UI thread?

## Current scope

The current automated flow measures typing in a note-to-self chat and produces:

- Keypress latency summary (`avg`, `best`, `worst`) from UI tests.
- Symbolized stack sampling for work happening during keypress windows.
- Flamegraph output for per-keypress averaged stacks.

This is a first pass and does not yet preload a synthetic 10k-message chat fixture.

## One-liner summary

Run:

```bash
just ios-typing-perf
```

Output includes a single parseable summary line:

```text
typing-keypress-ms avg=<ms> best=<ms> worst=<ms> samples=<n> [bulk_total=<ms> bulk_per_char=<ms>]
```

## Symbolized keypress profile + flamegraph

Run:

```bash
just ios-typing-perf-keypress-profile-symbolized
```

This runs the typing test, attaches Time Profiler to the app process, and emits:

- `typing-keypress-ms ...` summary.
- `typing-keypress-top ...` ranked sampled frames during keypress intervals.
- `typing-keypress-folded path=...` folded stacks file.
- `typing-keypress-flamegraph path=...` SVG flamegraph.
- `time-profiler-trace path=...` trace bundle.

Add `--open` to auto-open the trace/flamegraph viewer path where applicable.

## Samply route

Run:

```bash
just ios-typing-perf-samply
```

Notes:

- `samply` is available in the dev shell via `flake.nix`.
- On macOS simulator attach, samply may panic with an upstream `InvalidAddress` issue.
- The script auto-codesigns a local `samply` copy for attach profiling.
- If samply attach remains unstable, the script falls back to `ios-typing-perf-keypress-profile-symbolized` by default so the command still returns useful output.

Disable fallback if you want strict samply-only behavior:

```bash
PIKA_UI_TYPING_PERF_SAMPLY_FALLBACK_XCTRACE=0 just ios-typing-perf-samply
```

## Key instrumentation points

- UI tests emit keypress signposts (`composer_keypress`) around measured keystrokes.
- App-side signposts can also be enabled to bracket composer text-change handling.
- Time Profiler analysis intersects sampled stacks with keypress intervals and reports per-keypress averages.

## Environment knobs

Common knobs:

- `PIKA_UI_TYPING_PERF_CHARS` (default `120`)
- `PIKA_UI_TYPING_PERF_WARMUP` (default `10`)
- `PIKA_UI_TYPING_PERF_START_DELAY_MS` (default varies by script)
- `PIKA_UI_TYPING_PERF_TRACE_DIR` (default `artifacts/perf`)

Samply-specific knobs:

- `PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_ATTEMPTS`
- `PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_DELAY_S`
- `PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_TIMEOUT_S`
- `PIKA_UI_TYPING_PERF_SAMPLY_FALLBACK_XCTRACE`

## iOS runtime / destination troubleshooting

If builds fail with destination/runtime mismatch, run:

```bash
just doctor-ios
```

The updated iOS tooling scripts prefer an Xcode + simulator runtime pairing compatible with the active `iphonesimulator` SDK and print guidance when runtime components are missing.
2 changes: 2 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,8 @@
pkgs.findutils
pkgs.gnugrep
pkgs.gnused
pkgs.inferno
pkgs.samply
cargoDinghy
pkgs.age
pkgs.age-plugin-yubikey
Expand Down
30 changes: 28 additions & 2 deletions ios/Sources/Views/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import MarkdownUI
import PhotosUI
import AVFAudio
import UniformTypeIdentifiers
import os.signpost

struct ChatView: View {
let chatId: String
Expand Down Expand Up @@ -47,6 +48,7 @@ struct ChatView: View {
@FocusState private var isInputFocused: Bool

private let scrollButtonBottomPadding: CGFloat = 12
private static let typingPerfSignpostLog = OSLog(subsystem: "org.pikachat.pika", category: .pointsOfInterest)

init(
chatId: String,
Expand Down Expand Up @@ -645,7 +647,21 @@ struct ChatView: View {
onSend: { sendMessage() },
onStartVoiceRecording: { startVoiceRecording() }
)
.onChangeCompat(of: messageText) { newValue in
.onChangeCompat(of: messageText, withOld: { oldValue, newValue in
let signpostEnabled = envBool("PIKA_UI_TYPING_PERF_APP_SIGNPOSTS", defaultValue: false)
let lengthDelta = newValue.count - oldValue.count
let likelyKeypress = isInputFocused && abs(lengthDelta) == 1
var signpostID: OSSignpostID?
if signpostEnabled && likelyKeypress {
let id = OSSignpostID(log: Self.typingPerfSignpostLog)
os_signpost(.begin, log: Self.typingPerfSignpostLog, name: "composer_keypress", signpostID: id, "delta=%{public}d", lengthDelta)
signpostID = id
}
defer {
if let signpostID {
os_signpost(.end, log: Self.typingPerfSignpostLog, name: "composer_keypress", signpostID: signpostID, "delta=%{public}d", lengthDelta)
}
}
if chat.isGroup {
if let atIdx = newValue.lastIndex(of: "@") {
let prefix = newValue[..<atIdx]
Expand All @@ -671,7 +687,7 @@ struct ChatView: View {
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
onTypingStarted?()
}
}
})
.onChangeCompat(of: selectedPhotoItem) { item in
guard let item else { return }
Task {
Expand Down Expand Up @@ -767,6 +783,16 @@ struct ChatView: View {
}
}

private func envBool(_ key: String, defaultValue: Bool) -> Bool {
let raw = (ProcessInfo.processInfo.environment[key] ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if raw.isEmpty { return defaultValue }
if raw == "1" || raw == "true" || raw == "yes" || raw == "on" { return true }
if raw == "0" || raw == "false" || raw == "no" || raw == "off" { return false }
return defaultValue
}

private func startVoiceRecording() {
Task {
let granted = await CallMicrophonePermission.ensureGranted()
Expand Down
203 changes: 203 additions & 0 deletions ios/UITests/TypingPerfUITests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import XCTest
import QuartzCore
import os.signpost

final class TypingPerfUITests: XCTestCase {
private let typingSignpostLog = OSLog(subsystem: "org.pikachat.pika.uitests", category: .pointsOfInterest)

func testTypingLatencySummary() throws {
let app = XCUIApplication()
app.launchEnvironment["PIKA_UI_TEST_RESET"] = "1"
app.launchEnvironment["PIKA_DISABLE_NETWORK"] = "1"
app.launchEnvironment["PIKA_UI_TYPING_PERF_APP_SIGNPOSTS"] = ProcessInfo.processInfo.environment["PIKA_UI_TYPING_PERF_APP_SIGNPOSTS"] ?? "1"
app.launch()

ensureLoggedIn(app)
let myNpub = openProfileAndReadNpub(app)
closeProfile(app)
openNewChatFromChatList(app)
openNoteToSelfChat(app, npub: myNpub)

let composer = waitForChatComposer(app, timeout: 10)
XCTAssertTrue(composer.waitForExistence(timeout: 10), "Composer missing")
composer.tap()

let warmupChars = envInt("PIKA_UI_TYPING_PERF_WARMUP", defaultValue: 10)
let measuredChars = envInt("PIKA_UI_TYPING_PERF_CHARS", defaultValue: 120)
let startDelayMs = envInt("PIKA_UI_TYPING_PERF_START_DELAY_MS", defaultValue: 0)
let sampleText = makeSampleText(length: warmupChars + measuredChars)
let keypressSignposts = envBool("PIKA_UI_TYPING_PERF_SIGNPOSTS", defaultValue: true)

if startDelayMs > 0 {
Thread.sleep(forTimeInterval: TimeInterval(startDelayMs) / 1_000.0)
}

var keypressTimesMs: [Double] = []
keypressTimesMs.reserveCapacity(measuredChars)

for (idx, ch) in sampleText.enumerated() {
let isMeasured = idx >= warmupChars
let measuredIdx = idx - warmupChars
var signpostID: OSSignpostID?
if keypressSignposts, isMeasured {
let id = OSSignpostID(log: typingSignpostLog)
os_signpost(.begin, log: typingSignpostLog, name: "composer_keypress", signpostID: id, "index=%{public}d", measuredIdx)
signpostID = id
}

let start = CACurrentMediaTime()
composer.typeText(String(ch))
let elapsedMs = (CACurrentMediaTime() - start) * 1_000.0
if isMeasured {
keypressTimesMs.append(elapsedMs)
}
if let signpostID {
os_signpost(.end, log: typingSignpostLog, name: "composer_keypress", signpostID: signpostID, "index=%{public}d", measuredIdx)
}
}

let avg = keypressTimesMs.reduce(0.0, +) / Double(max(1, keypressTimesMs.count))
let best = keypressTimesMs.min() ?? 0
let worst = keypressTimesMs.max() ?? 0
print(
String(
format: "PIKA_TYPING_PERF avg_ms=%.3f best_ms=%.3f worst_ms=%.3f samples=%d warmup=%d",
avg,
best,
worst,
keypressTimesMs.count,
warmupChars
)
)
}

func testTypingBulkSummary() throws {
let app = XCUIApplication()
app.launchEnvironment["PIKA_UI_TEST_RESET"] = "1"
app.launchEnvironment["PIKA_DISABLE_NETWORK"] = "1"
app.launchEnvironment["PIKA_UI_TYPING_PERF_APP_SIGNPOSTS"] = ProcessInfo.processInfo.environment["PIKA_UI_TYPING_PERF_APP_SIGNPOSTS"] ?? "1"
app.launch()

ensureLoggedIn(app)
let myNpub = openProfileAndReadNpub(app)
closeProfile(app)
openNewChatFromChatList(app)
openNoteToSelfChat(app, npub: myNpub)

let composer = waitForChatComposer(app, timeout: 10)
XCTAssertTrue(composer.waitForExistence(timeout: 10), "Composer missing")
composer.tap()

let chars = envInt("PIKA_UI_TYPING_PERF_CHARS", defaultValue: 120)
let text = makeSampleText(length: chars)

let signpostID = OSSignpostID(log: typingSignpostLog)
os_signpost(.begin, log: typingSignpostLog, name: "composer_bulk_typeText", signpostID: signpostID, "chars=%{public}d", chars)
let start = CACurrentMediaTime()
composer.typeText(text)
let totalMs = (CACurrentMediaTime() - start) * 1_000.0
os_signpost(.end, log: typingSignpostLog, name: "composer_bulk_typeText", signpostID: signpostID, "chars=%{public}d", chars)

let perCharMs = totalMs / Double(max(1, chars))
print(String(format: "PIKA_TYPING_BULK total_ms=%.3f per_char_ms=%.3f chars=%d", totalMs, perCharMs, chars))
}

private func envInt(_ key: String, defaultValue: Int) -> Int {
let raw = ProcessInfo.processInfo.environment[key] ?? ""
guard let parsed = Int(raw), parsed > 0 else { return defaultValue }
return parsed
}

private func envBool(_ key: String, defaultValue: Bool) -> Bool {
let raw = (ProcessInfo.processInfo.environment[key] ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if raw.isEmpty { return defaultValue }
if raw == "1" || raw == "true" || raw == "yes" || raw == "on" { return true }
if raw == "0" || raw == "false" || raw == "no" || raw == "off" { return false }
return defaultValue
}

private func makeSampleText(length: Int) -> String {
let seed = "the_quick_brown_fox_jumps_over_the_lazy_dog_0123456789 "
guard length > 0 else { return "" }
var out = ""
out.reserveCapacity(length)
while out.count < length {
out += seed
}
return String(out.prefix(length))
}

private func ensureLoggedIn(_ app: XCUIApplication) {
let createAccount = app.buttons.matching(identifier: "login_create_account").firstMatch
if createAccount.waitForExistence(timeout: 2) {
createAccount.tap()
}
let chatsNavBar = app.navigationBars["Chats"]
XCTAssertTrue(chatsNavBar.waitForExistence(timeout: 15), "Chats screen not visible")
}

private func openProfileAndReadNpub(_ app: XCUIApplication) -> String {
let myNpubBtn = app.buttons.matching(identifier: "chatlist_my_npub").firstMatch
XCTAssertTrue(myNpubBtn.waitForExistence(timeout: 5), "Profile button missing")
myNpubBtn.tap()

let profileNav = app.navigationBars["Profile"]
XCTAssertTrue(profileNav.waitForExistence(timeout: 5), "Profile sheet missing")

let npubValue = app.staticTexts.matching(identifier: "chatlist_my_npub_value").firstMatch
XCTAssertTrue(npubValue.waitForExistence(timeout: 5), "npub value missing")
let npub = npubValue.label
XCTAssertTrue(npub.hasPrefix("npub1"), "Expected npub1..., got: \(npub)")
return npub
}

private func closeProfile(_ app: XCUIApplication) {
let close = app.buttons.matching(identifier: "chatlist_my_npub_close").firstMatch
if close.exists {
close.tap()
} else {
app.navigationBars["Profile"].buttons.element(boundBy: 0).tap()
}
}

private func openNewChatFromChatList(_ app: XCUIApplication) {
let newChat = app.buttons.matching(identifier: "chatlist_new_chat").firstMatch
XCTAssertTrue(newChat.waitForExistence(timeout: 5), "New chat button missing")
newChat.tap()

let nav = app.navigationBars["New Chat"]
if nav.waitForExistence(timeout: 2) {
return
}

let menuItem = app.buttons["New Chat"].firstMatch
XCTAssertTrue(menuItem.waitForExistence(timeout: 5), "New Chat menu item missing")
menuItem.tap()
XCTAssertTrue(nav.waitForExistence(timeout: 10), "New Chat screen not visible")
}

private func openNoteToSelfChat(_ app: XCUIApplication, npub: String) {
let peer = app.descendants(matching: .any).matching(identifier: "newchat_peer_npub").firstMatch
XCTAssertTrue(peer.waitForExistence(timeout: 10), "Peer npub field missing")
peer.tap()
peer.typeText(npub)

let start = app.buttons.matching(identifier: "newchat_start").firstMatch
XCTAssertTrue(start.waitForExistence(timeout: 5), "Start chat button missing")
start.tap()
}

private func waitForChatComposer(_ app: XCUIApplication, timeout: TimeInterval) -> XCUIElement {
let textView = app.textViews.matching(identifier: "chat_message_input").firstMatch
let textField = app.textFields.matching(identifier: "chat_message_input").firstMatch
let deadline = Date().addingTimeInterval(timeout)

while Date() < deadline {
if textView.exists { return textView }
if textField.exists { return textField }
Thread.sleep(forTimeInterval: 0.1)
}

return textView
}
}
Loading
Loading