feat(ios): W3 UI layer + integration CI + ntfy docs
T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
79
ios/IntegrationTests/ReplayTests.swift
Normal file
79
ios/IntegrationTests/ReplayTests.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// ReplayTests.swift — T-iOS-16 测试 3:>1 MiB 单帧回放(吸收 spike③ + 对抗变体)
|
||||
//
|
||||
// ring-buffer 回放是单帧全量(src/session/session.ts attachWs → buffer.snapshot()),
|
||||
// JSON 会把控制字节 \uXXXX 转义膨胀 1-6×(src/protocol.ts:186):
|
||||
// - 复现分支:平台默认 maximumMessageSize=1 MiB 收不下(withKnownIssue 常驻记录,
|
||||
// 若"意外通过"说明平台行为变了,须重新评估 Tunables.maxWSMessageBytes)
|
||||
// - 修复分支:Tunables.maxWSMessageBytes(16 MiB)→ 回放完整
|
||||
// - 对抗变体:ESC/C0 密集输出(转义膨胀 ~6×)→ 仍完整
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
|
||||
@Suite("T-iOS-16 回放上限(真服务器)", .serialized, .timeLimit(.minutes(5)))
|
||||
struct ReplayTests {
|
||||
|
||||
@Test(">1MiB 单帧回放 — 默认 1MiB 上限复现失败(known issue);Tunables 16MiB 成功")
|
||||
func replayOver1MiBFailsAtDefaultLimitAndSucceedsAt16MiB() async throws {
|
||||
let server = try await ServerHarness.shared.server()
|
||||
|
||||
// 造数据:attach(null) 后灌 >1MiB 普通输出,再 detach(PTY/ring 存活)。
|
||||
let generator = WSTestClient(server: server, origin: server.origin)
|
||||
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
|
||||
try await generator.send(shellCommand(
|
||||
"perl -e 'print \"a\" x \(HarnessTunables.bulkOutputBytes)'"))
|
||||
let produced = try await accumulateOutputBytes(
|
||||
client: generator, minBytes: HarnessTunables.bulkOutputBytes)
|
||||
#expect(produced >= HarnessTunables.bulkOutputBytes)
|
||||
generator.close()
|
||||
|
||||
// 复现分支:平台默认 maximumMessageSize(1 MiB) → 回放必失败。
|
||||
let defaultClient = WSTestClient(server: server, origin: server.origin, maxMessageBytes: nil)
|
||||
let defaultResult = await replayResult(
|
||||
client: defaultClient, sessionId: sessionId, minBytes: HarnessTunables.bulkOutputBytes)
|
||||
defaultClient.close()
|
||||
if case .failure(let error) = defaultResult {
|
||||
#expect(isMessageTooLongError(error),
|
||||
"失败模式应为 NSPOSIXErrorDomain EMSGSIZE(40)/ENOBUFS(55),实际 \(describeError(error))")
|
||||
}
|
||||
withKnownIssue("默认 maximumMessageSize=1MiB 收不下 >1MiB 单帧 ring-buffer 回放 — 平台限制,修法即 Tunables.maxWSMessageBytes=16MiB(若此处'意外通过',说明平台行为变了,须重新评估)") {
|
||||
_ = try defaultResult.get()
|
||||
}
|
||||
|
||||
// 修复分支:16 MiB → 全量回放成功,attached 采用同一 sessionId。
|
||||
let bigClient = WSTestClient(server: server, origin: server.origin)
|
||||
let summary = try await replayResult(
|
||||
client: bigClient, sessionId: sessionId, minBytes: HarnessTunables.bulkOutputBytes).get()
|
||||
#expect(summary.adoptedSessionId == sessionId)
|
||||
#expect(summary.totalOutputBytes >= HarnessTunables.bulkOutputBytes)
|
||||
bigClient.close()
|
||||
await killSessionBestEffort(server: server, id: sessionId)
|
||||
}
|
||||
|
||||
@Test("对抗: ESC/C0 密集输出(JSON 转义膨胀~6×)回放,16MiB 仍完整")
|
||||
func escDenseReplayStillSucceedsAt16MiB() async throws {
|
||||
let server = try await ServerHarness.shared.server()
|
||||
|
||||
let generator = WSTestClient(server: server, origin: server.origin)
|
||||
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
|
||||
// perl 的 \e = ESC(0x1B):每 4 原始字节 JSON 转义成 9 字节("[0m")。
|
||||
try await generator.send(shellCommand(
|
||||
"perl -e 'print \"\\e[0m\" x \(HarnessTunables.escSequenceRepeats)'"))
|
||||
let produced = try await accumulateOutputBytes(
|
||||
client: generator, minBytes: HarnessTunables.escSequenceRawBytes)
|
||||
#expect(produced >= HarnessTunables.escSequenceRawBytes)
|
||||
generator.close()
|
||||
|
||||
let bigClient = WSTestClient(server: server, origin: server.origin)
|
||||
let summary = try await replayResult(
|
||||
client: bigClient, sessionId: sessionId,
|
||||
minBytes: HarnessTunables.escSequenceRawBytes).get()
|
||||
#expect(summary.adoptedSessionId == sessionId)
|
||||
#expect(summary.totalOutputBytes >= HarnessTunables.escSequenceRawBytes)
|
||||
#expect(summary.containsSoftReset, "回放应含 ESC[0m(转义序列在回放中原样保留)")
|
||||
bigClient.close()
|
||||
await killSessionBestEffort(server: server, id: sessionId)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user