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
251 lines
9.9 KiB
Swift
251 lines
9.9 KiB
Swift
import Foundation
|
||
import TestSupport
|
||
import Testing
|
||
import WireProtocol
|
||
@testable import WebTerm
|
||
|
||
/// T-iOS-27 · Diff 查看器 —— `DiffFetcher`(App 层,GET /projects/diff)。
|
||
///
|
||
/// 覆盖面(Steps 测试先行):
|
||
/// - 请求形状:GET + path/staged 严格百分号编码 + **无 Origin**(RO 端点,
|
||
/// §3.4 iff-G 铁律;服务器路由无 requireAllowedOrigin,src/server.ts:601-618);
|
||
/// - `staged` 序列化为 `1`/`0` —— 服务器精确匹配 `=== '1'`(src/server.ts:613);
|
||
/// - `DiffResult{files,staged,truncated}` 宽容解码(镜像 public/diff.ts
|
||
/// normalizeDiffResult:顶层三键缺一 → 整体无效;畸形 file/hunk/line 逐条
|
||
/// 丢弃;未知 kind → .context、未知 status → .modified——服务器是不可信输入源);
|
||
/// - 400 → .pathInvalid、404 → .projectNotFound(SEC-H7 三叉校验失败)、
|
||
/// 其余状态 → .unexpectedStatus;
|
||
/// - 空 path 客户端先拒(镜像服务器 400 规则),零网络。
|
||
struct DiffFetcherTests {
|
||
// MARK: - Fixtures
|
||
|
||
private static func makeEndpoint() throws -> HostEndpoint {
|
||
let url = try #require(URL(string: "http://127.0.0.1:3000"))
|
||
return try #require(HostEndpoint(baseURL: url))
|
||
}
|
||
|
||
private static func makeFetcher() throws -> (DiffFetcher, FakeHTTPTransport) {
|
||
let fake = FakeHTTPTransport()
|
||
let fetcher = DiffFetcher(endpoint: try makeEndpoint(), http: fake)
|
||
return (fetcher, fake)
|
||
}
|
||
|
||
private static func url(_ s: String) throws -> URL {
|
||
try #require(URL(string: s))
|
||
}
|
||
|
||
/// 一份完整合法响应体(modified 文件 + 1 hunk + 4 行,覆盖 4 种 kind)。
|
||
private static let fullBody = Data("""
|
||
{
|
||
"files": [
|
||
{
|
||
"oldPath": "src/a.ts", "newPath": "src/a.ts", "status": "modified",
|
||
"added": 2, "removed": 1, "binary": false,
|
||
"hunks": [
|
||
{ "header": "@@ -1,3 +1,4 @@",
|
||
"lines": [
|
||
{ "kind": "context", "text": "unchanged" },
|
||
{ "kind": "removed", "text": "old line" },
|
||
{ "kind": "added", "text": "new line" },
|
||
{ "kind": "meta", "text": "No newline at end of file" }
|
||
] }
|
||
]
|
||
}
|
||
],
|
||
"staged": false,
|
||
"truncated": false
|
||
}
|
||
""".utf8)
|
||
|
||
// MARK: - 请求形状
|
||
|
||
@Test("GET /projects/diff?path=<enc>&staged=0,无 Origin(RO 端点,iff-G 铁律)")
|
||
func requestShapeIsReadOnlyGetWithoutOrigin() async throws {
|
||
let (fetcher, fake) = try Self.makeFetcher()
|
||
let expected = try Self.url(
|
||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||
)
|
||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||
|
||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||
|
||
let recorded = await fake.recordedRequests
|
||
let request = try #require(recorded.first)
|
||
#expect(recorded.count == 1)
|
||
#expect(request.httpMethod == "GET")
|
||
#expect(request.url == expected)
|
||
// RO 一律不带 Origin:服务器若把该端点改为 G,这里先红(§3.4 铁律)。
|
||
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
|
||
}
|
||
|
||
@Test("staged=true → staged=1(服务器 === '1' 精确匹配,src/server.ts:613)")
|
||
func stagedSerializesAsOne() async throws {
|
||
let (fetcher, fake) = try Self.makeFetcher()
|
||
let expected = try Self.url(
|
||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1"
|
||
)
|
||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||
|
||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: true)
|
||
|
||
let recorded = await fake.recordedRequests
|
||
#expect(recorded.first?.url == expected)
|
||
}
|
||
|
||
@Test("path 严格百分号编码:空格/&/+/= 全部转义(unreserved-only 集)")
|
||
func pathIsStrictlyPercentEncoded() async throws {
|
||
let (fetcher, fake) = try Self.makeFetcher()
|
||
// " " → %20、"&" → %26、"+" → %2B、"=" → %3D、"/" → %2F
|
||
let expected = try Self.url(
|
||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Fa%20b%26c%2Bd%3De&staged=0"
|
||
)
|
||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||
|
||
_ = try await fetcher.fetch(path: "/tmp/a b&c+d=e", staged: false)
|
||
|
||
let recorded = await fake.recordedRequests
|
||
#expect(recorded.first?.url == expected)
|
||
}
|
||
|
||
@Test("空 path → .invalidRequest,零网络(镜像服务器 400 规则,先于 I/O 拒绝)")
|
||
func emptyPathRejectedBeforeNetwork() async throws {
|
||
let (fetcher, fake) = try Self.makeFetcher()
|
||
|
||
await #expect(throws: DiffFetchError.invalidRequest) {
|
||
_ = try await fetcher.fetch(path: "", staged: false)
|
||
}
|
||
|
||
let recorded = await fake.recordedRequests
|
||
#expect(recorded.isEmpty)
|
||
}
|
||
|
||
// MARK: - 解码(服务器为不可信输入源)
|
||
|
||
@Test("200 完整体 → DiffResult 各字段逐一到位")
|
||
func decodesFullBody() async throws {
|
||
let (fetcher, fake) = try Self.makeFetcher()
|
||
let expected = try Self.url(
|
||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||
)
|
||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||
|
||
let result = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||
|
||
#expect(result.staged == false)
|
||
#expect(result.truncated == false)
|
||
#expect(result.files.count == 1)
|
||
let file = try #require(result.files.first)
|
||
#expect(file.newPath == "src/a.ts")
|
||
#expect(file.status == .modified)
|
||
#expect(file.added == 2)
|
||
#expect(file.removed == 1)
|
||
#expect(file.binary == false)
|
||
let hunk = try #require(file.hunks.first)
|
||
#expect(hunk.header == "@@ -1,3 +1,4 @@")
|
||
#expect(hunk.lines.map(\.kind) == [.context, .removed, .added, .meta])
|
||
#expect(hunk.lines.map(\.text) == [
|
||
"unchanged", "old line", "new line", "No newline at end of file",
|
||
])
|
||
}
|
||
|
||
@Test("宽容解码:畸形 file 条目丢弃、未知 kind → .context、未知 status → .modified")
|
||
func tolerantDecodingDropsMalformedEntries() async throws {
|
||
let (fetcher, fake) = try Self.makeFetcher()
|
||
let body = Data("""
|
||
{
|
||
"files": [
|
||
42,
|
||
{ "oldPath": 1, "newPath": "x" },
|
||
{
|
||
"oldPath": "u.txt", "newPath": "u.txt", "status": "exotic-future",
|
||
"added": 0, "removed": 0, "binary": false,
|
||
"hunks": [
|
||
{ "lines": [] },
|
||
{ "header": "@@ -1 +1 @@",
|
||
"lines": [
|
||
{ "kind": "sparkle", "text": "mystery" },
|
||
{ "kind": "added" },
|
||
{ "kind": "added", "text": "kept" }
|
||
] }
|
||
]
|
||
}
|
||
],
|
||
"staged": true,
|
||
"truncated": true
|
||
}
|
||
""".utf8)
|
||
let expected = try Self.url(
|
||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1"
|
||
)
|
||
await fake.queueSuccess(url: expected, body: body)
|
||
|
||
let result = try await fetcher.fetch(path: "/tmp/repo", staged: true)
|
||
|
||
#expect(result.truncated == true)
|
||
#expect(result.files.count == 1) // 42 与缺字段对象被丢弃
|
||
let file = try #require(result.files.first)
|
||
#expect(file.status == .modified) // 未知 status 降级
|
||
#expect(file.hunks.count == 1) // 缺 header 的 hunk 被丢弃
|
||
let lines = try #require(file.hunks.first).lines
|
||
#expect(lines.count == 2) // 缺 text 的行被丢弃
|
||
#expect(lines.first?.kind == .context) // 未知 kind 降级(同 web df-context 兜底)
|
||
#expect(lines.last?.text == "kept")
|
||
}
|
||
|
||
@Test("顶层畸形(缺键 / 非对象 / 非 JSON)→ .invalidResponse", arguments: [
|
||
#"{"staged": false, "truncated": false}"#,
|
||
#"[1, 2, 3]"#,
|
||
"not json at all",
|
||
])
|
||
func malformedTopLevelIsInvalidResponse(raw: String) async throws {
|
||
let (fetcher, fake) = try Self.makeFetcher()
|
||
let expected = try Self.url(
|
||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||
)
|
||
await fake.queueSuccess(url: expected, body: Data(raw.utf8))
|
||
|
||
await #expect(throws: DiffFetchError.invalidResponse) {
|
||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||
}
|
||
}
|
||
|
||
// MARK: - 错误状态映射(route 层语义,src/server.ts:602-618)
|
||
|
||
@Test("400 → .pathInvalid、404 → .projectNotFound、500 → .unexpectedStatus(500)")
|
||
func statusCodesMapToTypedErrors() async throws {
|
||
let cases: [(Int, DiffFetchError)] = [
|
||
(400, .pathInvalid),
|
||
(404, .projectNotFound),
|
||
(500, .unexpectedStatus(500)),
|
||
(403, .unexpectedStatus(403)),
|
||
]
|
||
for (status, expectedError) in cases {
|
||
let (fetcher, fake) = try Self.makeFetcher()
|
||
let expected = try Self.url(
|
||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||
)
|
||
await fake.queueSuccess(
|
||
url: expected, status: status, body: Data(#"{"error":"x"}"#.utf8)
|
||
)
|
||
|
||
await #expect(throws: expectedError) {
|
||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||
}
|
||
}
|
||
}
|
||
|
||
@Test("传输层错误原样上抛(不吞、不错误分类)")
|
||
func transportErrorsPropagate() async throws {
|
||
struct Boom: Error {}
|
||
let (fetcher, fake) = try Self.makeFetcher()
|
||
let expected = try Self.url(
|
||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||
)
|
||
await fake.queueFailure(url: expected, error: Boom())
|
||
|
||
await #expect(throws: Boom.self) {
|
||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||
}
|
||
}
|
||
}
|