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:
434
ios/App/WebTermTests/SessionThumbnailTests.swift
Normal file
434
ios/App/WebTermTests/SessionThumbnailTests.swift
Normal 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
|
||||
/// 变化 → 新键重渲染;nil(P1 前旧服务器)→ 渲染一次后永久命中(无失效
|
||||
/// 信号时宁可陈旧也不无界重渲染,文档化降级);
|
||||
/// - 并发上限调度器:离屏渲染受具名常量上限(2)约束,列表滚动绝不无界
|
||||
/// spawn 终端;排队 FIFO、permit 转移;全程零真实等待(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 为 nil(P1 前服务器)→ 渲染一次后命中缓存(文档化降级)")
|
||||
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 FIFO:permit 按排队顺序转移")
|
||||
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 不匹配 → placeholder,renderer 不调")
|
||||
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 → placeholder,renderer 不调")
|
||||
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("几何出服务器 resizeRange(0 / 1001)→ placeholder,renderer 不调", 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 → 逐出 B,A/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*1024(src/config.ts:46,env 可调)——
|
||||
// 客户端防御上限必须至少覆盖默认值。
|
||||
#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
|
||||
}
|
||||
Reference in New Issue
Block a user