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:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -0,0 +1,250 @@
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)
}
}
}