feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push

T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
This commit is contained in:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -0,0 +1,434 @@
import APIClient
import Foundation
import Testing
import UIKit
import WireProtocol
@testable import WebTerm
/// T-iOS-28 · offscreen SwiftTerm
///
/// Steps
/// - (sessionId, lastOutputAt)lastOutputAt
/// nilP1
///
/// - 2
/// spawn FIFOpermit continuation
/// FakeClock.waitForSleepers
/// - 404// placeholder
/// - id / data / resizeRange
/// placeholder renderer data SwiftTerm
/// - ANSI nil
@MainActor
@Suite("SessionThumbnail")
struct SessionThumbnailTests {
// MARK: - Fixtures
private static func makeEndpoint() -> HostEndpoint {
guard let url = URL(string: "http://192.168.1.20:3000"),
let endpoint = HostEndpoint(baseURL: url) else {
fatalError("fixture endpoint must be valid")
}
return endpoint
}
private static func makeRequest(
sessionId: UUID = UUID(), lastOutputAt: Int? = 1_000
) -> SessionThumbnailRequest {
SessionThumbnailRequest(
endpoint: makeEndpoint(), sessionId: sessionId, lastOutputAt: lastOutputAt
)
}
private static func makePreview(
for request: SessionThumbnailRequest, cols: Int = 80, rows: Int = 24,
data: String = "hello"
) -> SessionPreview {
SessionPreview(id: request.sessionId, cols: cols, rows: rows, data: data)
}
/// actor loader/renderer
@MainActor
private final class Recorder {
private(set) var loaderCalls: [SessionThumbnailRequest] = []
private(set) var rendererCalls: [(data: String, cols: Int, rows: Int)] = []
func recordLoad(_ request: SessionThumbnailRequest) {
loaderCalls = loaderCalls + [request]
}
func recordRender(data: String, cols: Int, rows: Int) {
rendererCalls = rendererCalls + [(data, cols, rows)]
}
}
/// loader open
/// continuation FakeClock.waitForSleepers
@MainActor
private final class LoaderHold {
private var held: [CheckedContinuation<Void, Never>] = []
private var barriers: [(target: Int, cont: CheckedContinuation<Void, Never>)] = []
private var isOpen = false
var heldCount: Int { held.count }
func pass() async {
if isOpen { return }
await withCheckedContinuation { cont in
held = held + [cont]
notifyBarriers()
}
}
/// target loader
func waitUntilHeld(count target: Int) async {
if held.count >= target { return }
await withCheckedContinuation { cont in
barriers = barriers + [(target, cont)]
}
}
/// loader loader
func open() {
isOpen = true
let releasing = held
held = []
for cont in releasing { cont.resume() }
}
private func notifyBarriers() {
let met = barriers.filter { $0.target <= held.count }
barriers = barriers.filter { $0.target > held.count }
for barrier in met { barrier.cont.resume() }
}
}
private static func makePipeline(
recorder: Recorder,
gate: SessionThumbnailRenderGate = SessionThumbnailRenderGate(
limit: SessionThumbnailTunable.maxConcurrentRenders
),
maxCacheEntries: Int = SessionThumbnailTunable.maxCacheEntries,
hold: LoaderHold? = nil,
loaderError: (any Error)? = nil,
preview: ((SessionThumbnailRequest) -> SessionPreview)? = nil,
rendered: UIImage? = UIImage()
) -> SessionThumbnailPipeline {
SessionThumbnailPipeline(
loader: { request in
if let hold { await hold.pass() }
recorder.recordLoad(request)
if let loaderError { throw loaderError }
return preview?(request) ?? Self.makePreview(for: request)
},
renderer: { data, cols, rows in
recorder.recordRender(data: data, cols: cols, rows: rows)
return rendered
},
gate: gate,
maxCacheEntries: maxCacheEntries
)
}
// MARK: - (sessionId, lastOutputAt)
@Test("同键第二次请求命中缓存——loader 只调一次,返回同一图像")
func sameKeyHitsCache() async {
let recorder = Recorder()
let pipeline = Self.makePipeline(recorder: recorder)
let request = Self.makeRequest()
let first = await pipeline.thumbnail(for: request)
let second = await pipeline.thumbnail(for: request)
#expect(recorder.loaderCalls.count == 1)
#expect(first == second)
#expect(!first.isPlaceholder)
}
@Test("lastOutputAt 变化(会话有新输出)→ 新键重新渲染")
func newerLastOutputAtRerenders() async {
let recorder = Recorder()
let pipeline = Self.makePipeline(recorder: recorder)
let sessionId = UUID()
_ = await pipeline.thumbnail(for: Self.makeRequest(sessionId: sessionId, lastOutputAt: 1_000))
_ = await pipeline.thumbnail(for: Self.makeRequest(sessionId: sessionId, lastOutputAt: 2_000))
#expect(recorder.loaderCalls.count == 2)
}
@Test("lastOutputAt 为 nilP1 前服务器)→ 渲染一次后命中缓存(文档化降级)")
func nilLastOutputAtCachesOnce() async {
let recorder = Recorder()
let pipeline = Self.makePipeline(recorder: recorder)
let request = Self.makeRequest(lastOutputAt: nil)
_ = await pipeline.thumbnail(for: request)
_ = await pipeline.thumbnail(for: request)
#expect(recorder.loaderCalls.count == 1)
}
@Test("并发去重:同键两个并发请求 → loader 一次、两个调用者拿到同一结果")
func concurrentSameKeyDeduplicates() async {
let recorder = Recorder()
let hold = LoaderHold()
let pipeline = Self.makePipeline(recorder: recorder, hold: hold)
let request = Self.makeRequest()
let taskA = Task { await pipeline.thumbnail(for: request) }
let taskB = Task { await pipeline.thumbnail(for: request) }
await hold.waitUntilHeld(count: 1) // loader
hold.open()
let a = await taskA.value
let b = await taskB.value
#expect(recorder.loaderCalls.count == 1)
#expect(a == b)
}
// MARK: - spawn
@Test("5 个不同键并发:同时在渲染管线内的至多 maxConcurrentRenders(2),其余排队")
func concurrencyCapHoldsUnderBurst() async {
let recorder = Recorder()
let gate = SessionThumbnailRenderGate(limit: SessionThumbnailTunable.maxConcurrentRenders)
let hold = LoaderHold()
let pipeline = Self.makePipeline(recorder: recorder, gate: gate, hold: hold)
let requests = (0..<5).map { _ in Self.makeRequest() }
let tasks = requests.map { request in
Task { await pipeline.thumbnail(for: request) }
}
await hold.waitUntilHeld(count: 2) // loader
await gate.waitUntilWaiting(count: 3) //
#expect(gate.activeCount == 2)
#expect(gate.peakActiveCount == 2)
#expect(hold.heldCount == 2)
hold.open()
for task in tasks { _ = await task.value }
#expect(gate.peakActiveCount == SessionThumbnailTunable.maxConcurrentRenders)
#expect(recorder.loaderCalls.count == 5)
#expect(gate.activeCount == 0)
}
@Test("gate FIFOpermit 按排队顺序转移")
func gateIsFifo() async {
let gate = SessionThumbnailRenderGate(limit: 1)
await gate.acquire()
let order = OrderBox()
let third = Task {
await gate.acquire()
order.append(3)
gate.release()
}
await gate.waitUntilWaiting(count: 1)
let fourth = Task {
await gate.acquire()
order.append(4)
gate.release()
}
await gate.waitUntilWaiting(count: 2)
gate.release() // permit 3 4
_ = await third.value
_ = await fourth.value
#expect(order.values == [3, 4])
#expect(gate.activeCount == 0)
}
@MainActor
private final class OrderBox {
private(set) var values: [Int] = []
func append(_ value: Int) { values = values + [value] }
}
// MARK: - placeholder 404//
@Test("loader 抛错(如 404 sessionNotFound→ placeholder且失败同键缓存不重试")
func loaderFailureYieldsCachedPlaceholder() async {
let recorder = Recorder()
let pipeline = Self.makePipeline(
recorder: recorder, loaderError: APIClientError.sessionNotFound
)
let request = Self.makeRequest()
let first = await pipeline.thumbnail(for: request)
let second = await pipeline.thumbnail(for: request)
#expect(first.isPlaceholder)
#expect(second.isPlaceholder)
#expect(recorder.loaderCalls.count == 1) //
#expect(recorder.rendererCalls.isEmpty)
}
@Test("renderer 返回 nil快照失败→ placeholder")
func rendererNilYieldsPlaceholder() async {
let recorder = Recorder()
let pipeline = Self.makePipeline(recorder: recorder, rendered: nil)
let result = await pipeline.thumbnail(for: Self.makeRequest())
#expect(result.isPlaceholder)
#expect(recorder.rendererCalls.count == 1)
}
// MARK: - renderer
@Test("preview.id 与请求的 sessionId 不匹配 → placeholderrenderer 不调")
func mismatchedPreviewIdRejected() async {
let recorder = Recorder()
let pipeline = Self.makePipeline(recorder: recorder, preview: { _ in
SessionPreview(id: UUID(), cols: 80, rows: 24, data: "x") // id request.sessionId
})
let result = await pipeline.thumbnail(for: Self.makeRequest())
#expect(result.isPlaceholder)
#expect(recorder.rendererCalls.isEmpty)
}
@Test("data 超出 maxPreviewDataBytes → placeholderrenderer 不调")
func oversizedPreviewDataRejected() async {
let recorder = Recorder()
let oversized = String(
repeating: "a", count: SessionThumbnailTunable.maxPreviewDataBytes + 1
)
let pipeline = Self.makePipeline(recorder: recorder, preview: { request in
Self.makePreview(for: request, data: oversized)
})
let result = await pipeline.thumbnail(for: Self.makeRequest())
#expect(result.isPlaceholder)
#expect(recorder.rendererCalls.isEmpty)
}
@Test("几何出服务器 resizeRange0 / 1001→ placeholderrenderer 不调", arguments: [
(0, 24), (80, 0), (1_001, 24), (80, 1_001),
])
func outOfRangeGeometryRejected(cols: Int, rows: Int) async {
let recorder = Recorder()
let pipeline = Self.makePipeline(recorder: recorder, preview: { request in
Self.makePreview(for: request, cols: cols, rows: rows)
})
let result = await pipeline.thumbnail(for: Self.makeRequest())
#expect(result.isPlaceholder)
#expect(recorder.rendererCalls.isEmpty)
}
@Test("几何钳制合法值透传1→2镜像 web max(2,·));大值钳到缩略图上限")
func geometryClamping() {
let normal = SessionThumbnailPipeline.clampedGeometry(cols: 80, rows: 24)
#expect(normal?.cols == 80 && normal?.rows == 24)
let tiny = SessionThumbnailPipeline.clampedGeometry(cols: 1, rows: 1)
#expect(tiny?.cols == SessionThumbnailTunable.minRenderGrid)
#expect(tiny?.rows == SessionThumbnailTunable.minRenderGrid)
let huge = SessionThumbnailPipeline.clampedGeometry(cols: 1_000, rows: 1_000)
#expect(huge?.cols == SessionThumbnailTunable.maxRenderCols)
#expect(huge?.rows == SessionThumbnailTunable.maxRenderRows)
#expect(SessionThumbnailPipeline.clampedGeometry(cols: 0, rows: 24) == nil)
#expect(SessionThumbnailPipeline.clampedGeometry(cols: 80, rows: 1_001) == nil)
}
@Test("渲染几何真被钳制后才交给 renderer")
func rendererReceivesClampedGeometry() async {
let recorder = Recorder()
let pipeline = Self.makePipeline(recorder: recorder, preview: { request in
Self.makePreview(for: request, cols: 1_000, rows: 1)
})
_ = await pipeline.thumbnail(for: Self.makeRequest())
#expect(recorder.rendererCalls.count == 1)
#expect(recorder.rendererCalls.first?.cols == SessionThumbnailTunable.maxRenderCols)
#expect(recorder.rendererCalls.first?.rows == SessionThumbnailTunable.minRenderGrid)
}
// MARK: - LRU
@Test("LRU容量 3 时命中会前移——插 A,B,C、命中 A、插 D → 逐出 BA/C/D 仍在")
func lruEvictsLeastRecentlyUsed() async {
let recorder = Recorder()
let pipeline = Self.makePipeline(recorder: recorder, maxCacheEntries: 3)
let a = Self.makeRequest()
let b = Self.makeRequest()
let c = Self.makeRequest()
let d = Self.makeRequest()
_ = await pipeline.thumbnail(for: a)
_ = await pipeline.thumbnail(for: b)
_ = await pipeline.thumbnail(for: c)
_ = await pipeline.thumbnail(for: a) // bump A LRU B
_ = await pipeline.thumbnail(for: d) // B
#expect(recorder.loaderCalls.count == 4)
_ = await pipeline.thumbnail(for: b) // B
#expect(recorder.loaderCalls.count == 5)
_ = await pipeline.thumbnail(for: a) // C B A
#expect(recorder.loaderCalls.count == 5)
}
// MARK: -
@Test("具名常量在位:并发上限 2、缓存上限 > 0、数据上限覆盖服务器默认 24KB")
func tunablesArePinned() {
#expect(SessionThumbnailTunable.maxConcurrentRenders == 2)
#expect(SessionThumbnailTunable.maxCacheEntries > 0)
// previewBytes = 24*1024src/config.ts:46env
//
#expect(SessionThumbnailTunable.maxPreviewDataBytes >= 24 * 1024)
#expect(SessionThumbnailTunable.minRenderGrid == 2)
}
@Test("用户可见文案:中文具名常量、非空")
func copyIsNonEmptyChinese() {
#expect(!SessionThumbnailCopy.thumbnailLabel.isEmpty)
}
// MARK: - SwiftTerm
#if targetEnvironment(simulator)
@Test("离屏渲染小 ANSI 样例 → 非 nil、宽>高、非单色、宽度受快照预算约束")
func rendererSmokeProducesNonBlankImage() throws {
let sample = "\u{1b}[31mRED\u{1b}[0m plain \u{1b}[44m BLUE-BG \u{1b}[0m"
let image = SessionThumbnailRenderer.render(data: sample, cols: 20, rows: 5)
let snapshot = try #require(image)
#expect(snapshot.size.width > 0 && snapshot.size.height > 0)
#expect(snapshot.size.width > snapshot.size.height) // 20×5
#expect(snapshot.size.width <= SessionThumbnailRenderer.snapshotTargetWidth + 1)
#expect(Self.hasMultipleColors(snapshot)) // / = 线
}
/// RGBA 2 西
private static func hasMultipleColors(_ image: UIImage) -> Bool {
guard let cgImage = image.cgImage else { return false }
let width = min(cgImage.width, 64)
let height = min(cgImage.height, 64)
let bytesPerPixel = 4
var buffer = [UInt8](repeating: 0, count: width * height * bytesPerPixel)
guard let context = CGContext(
data: &buffer, width: width, height: height, bitsPerComponent: 8,
bytesPerRow: width * bytesPerPixel,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return false }
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
let first = Array(buffer.prefix(bytesPerPixel))
for pixel in stride(from: 0, to: buffer.count, by: bytesPerPixel)
where Array(buffer[pixel..<(pixel + bytesPerPixel)]) != first {
return true
}
return false
}
#endif
}