Files
web-terminal/ios/App/WebTermTests/DiffFetcherTests.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

251 lines
9.9 KiB
Swift
Raw Permalink 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 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 requireAllowedOriginsrc/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 .pathInvalid404 .projectNotFoundSEC-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无 OriginRO 端点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)
}
}
}