Files
web-terminal/ios/App/WebTermTests/SessionThumbnailTests.swift
Yaojia Wang f40b8f9400 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
2026-07-05 16:15:57 +02:00

435 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}