feat(ios): access-token support + git-panel endpoints across the package layer

APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four
distinct outcomes from the frozen contract, Cookie/Accept landed at the same
single header choke point that already enforces Origin-iff-G, plus the whole
project-ops surface the server has had since late July and iOS consumed none of:
/projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/
fetch, worktree create/remove/prune, GET /sessions, follow-up queue.

HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain
under the existing SecItemShim conventions (device-only, never synchronizable),
charset/length validated at the boundary, old token-less records still decode.

SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from
the same point that writes Origin, and a 401 handshake is a terminal
.unauthorized -- never entering the backoff loop, since retrying one wrong
shared token is a brute-force generator against the server's 10/min limiter.
This commit is contained in:
Yaojia Wang
2026-07-30 12:45:26 +02:00
parent c4f8b5b47f
commit 850531fd07
33 changed files with 4191 additions and 106 deletions

View File

@@ -0,0 +1,314 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// B1 · 访`WEBTERM_TOKEN` ios-completion §1.1
///
/// `src/http/auth.ts` + `src/server.ts:393-460`
/// - `POST /auth`body `{"token":"<t>"}``Content-Type: application/json`
/// - **`Accept` `text/html`** 302 204/401
/// - 204 **** `Set-Cookie: webterm_auth=`
/// - 204 **** `Set-Cookie` ********""
/// - 401 429 10 //IP
///
/// **** `Cookie: webterm_auth=<t>`
/// `Set-Cookie` cookie jar`Cookie` `Origin` ****
/// **** Origin `server.ts` Origin cookie
///
/// ** URL query**`?token=` bootstrap
struct AccessTokenTests {
private static let base = "http://192.168.1.5:3000"
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
/// 16512 `[A-Za-z0-9._~+/=-]` CLAUDE.md / config
private static let token = "s3cret-token_value.~+/="
private static let setCookieValue =
"webterm_auth=\(token); Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict"
private func makeEndpoint(_ base: String = AccessTokenTests.base) throws -> HostEndpoint {
let url = try #require(URL(string: base))
return try #require(HostEndpoint(baseURL: url))
}
private func routeURL(_ path: String) throws -> URL {
try #require(URL(string: Self.base + path))
}
private func makeClient(
token: String?, http: FakeHTTPTransport
) throws -> APIClient {
APIClient(endpoint: try makeEndpoint(), http: http, accessToken: token)
}
// MARK: - POST /auth
@Test("POST /auth:body 恰为 {\"token\":…}、Content-Type=JSON、Accept 不含 text/html、令牌绝不进 URL")
func authProbeRequestShapeIsFrozen() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
method: "POST", url: try routeURL("/auth"), status: 204,
headers: ["Set-Cookie": Self.setCookieValue]
)
// Act
_ = try await client.probeAccessToken(Self.token)
// Assert
let request = try #require(await http.recordedRequests.first)
#expect(request.httpMethod == "POST")
#expect(request.url == (try routeURL("/auth")))
#expect(request.url?.query == nil) // URL query
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
// /auth POST( cookie) Origin-iff-G Origin( Android )
#expect(request.value(forHTTPHeaderField: "Origin") == (try makeEndpoint()).originHeader)
let accept = try #require(request.value(forHTTPHeaderField: "Accept"))
#expect(!accept.contains("text/html")) // text/html 302
let body = try #require(request.httpBody)
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(Set(object.keys) == Set(["token"]))
#expect(object["token"] as? String == Self.token)
}
@Test("每个请求都带 Accept: application/json —— 未认证时服务器才回 401 JSON 而不是 302 登录页")
func everyRequestAcceptsJSONSoTheGateAnswers401NotARedirect() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: Self.token, http: http)
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
await http.queueSuccess(
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204
)
// Act
_ = try await client.liveSessions()
try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString)))
// Assert
let requests = await http.recordedRequests
#expect(requests.count == 2)
for request in requests {
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
}
}
// MARK: - POST /auth
@Test("204 + Set-Cookie ⇒ .valid(令牌正确,可保存)")
func probe204WithSetCookieMeansTokenValid() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
method: "POST", url: try routeURL("/auth"), status: 204,
headers: ["Set-Cookie": Self.setCookieValue]
)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .valid)
}
@Test("204 但无 Set-Cookie ⇒ .authDisabled(服务器没开鉴权),绝不报成 .valid")
func probe204WithoutSetCookieMeansAuthDisabledNotAuthenticated() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 204)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .authDisabled)
#expect(result != .valid) // ""
}
@Test("401 ⇒ .invalidToken(不是 .unauthorized —— /auth 自己的 401 是路由语义)")
func probe401MeansInvalidTokenNotGateUnauthorized() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
method: "POST", url: try routeURL("/auth"), status: 401,
body: Data(#"{"error":"invalid token"}"#.utf8)
)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .invalidToken)
}
@Test("429 ⇒ .rateLimited(10 次/分/IP,src/server.ts:399-402)")
func probe429MeansRateLimited() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 429)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .rateLimited)
}
@Test("其他状态码(500) ⇒ unexpectedStatus,不猜测")
func probeUnexpectedStatusThrows() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 500)
// Act + Assert
await #expect(throws: APIClientError.unexpectedStatus(500)) {
_ = try await client.probeAccessToken(Self.token)
}
}
@Test("形状非法的令牌(过短/越界字符/过长)联网前就拒:malformedToken,零请求")
func malformedTokenIsRejectedBeforeAnyNetworkIO() async throws {
// Arrange 16512 ,
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
let malformed = [
"short", // < 16
String(repeating: "a", count: 513), // > 512
"has space in it x", //
"has\r\nCRLF-injection-x", // CRLF()
"中文令牌中文令牌中文令牌中文令牌", // ASCII
]
// Act + Assert
for candidate in malformed {
await #expect(throws: APIClientError.malformedToken) {
_ = try await client.probeAccessToken(candidate)
}
}
#expect(await http.recordedRequests.isEmpty)
}
// MARK: - Cookie Origin
@Test("Cookie iff 配置了令牌:RO 带 Cookie 不带 Origin;G 带 Cookie **并且**带 Origin")
func cookieIsOrthogonalToTheOriginRule() async throws {
// Arrange
let http = FakeHTTPTransport()
let endpoint = try makeEndpoint()
let client = APIClient(endpoint: endpoint, http: http, accessToken: Self.token)
let id = try #require(UUID(uuidString: Self.sessionIdString))
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
await http.queueSuccess(
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204
)
// Act
_ = try await client.liveSessions()
try await client.killSession(id: id)
// Assert
let requests = await http.recordedRequests
let readOnly = try #require(requests.first)
let guarded = try #require(requests.last)
#expect(readOnly.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
#expect(readOnly.value(forHTTPHeaderField: "Origin") == nil) // RO Origin
#expect(guarded.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
#expect(guarded.value(forHTTPHeaderField: "Origin") == endpoint.originHeader) // Origin
}
@Test("未配置令牌 ⇒ 完全不带 Cookie 头(LAN 零配置行为不变)")
func noTokenConfiguredMeansNoCookieHeaderAtAll() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
// Act
_ = try await client.liveSessions()
// Assert
let request = try #require(await http.recordedRequests.first)
#expect(request.value(forHTTPHeaderField: "Cookie") == nil)
}
@Test("配置了形状非法的令牌 ⇒ 任何调用联网前抛 malformedToken(绝不静默发无认证请求)")
func malformedConfiguredTokenFailsFastOnEveryCall() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: "bad token", http: http)
// Act + Assert
await #expect(throws: APIClientError.malformedToken) {
_ = try await client.liveSessions()
}
#expect(await http.recordedRequests.isEmpty)
}
// MARK: - 401 .unauthorized
@Test("普通 RO 端点收 401 ⇒ 类型化 .unauthorized(不是 unexpectedStatus/网络错)")
func gate401OnReadOnlyEndpointSurfacesAsUnauthorized() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
url: try routeURL("/live-sessions"), status: 401,
body: Data(#"{"error":"authentication required"}"#.utf8)
)
// Act + Assert
await #expect(throws: APIClientError.unauthorized) {
_ = try await client.liveSessions()
}
}
@Test("普通 G 端点收 401 ⇒ 同样是 .unauthorized,且话术非空(UI 引导补令牌)")
func gate401OnGuardedEndpointSurfacesAsUnauthorized() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: Self.token, http: http)
await http.queueSuccess(
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 401
)
// Act + Assert
await #expect(throws: APIClientError.unauthorized) {
try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString)))
}
#expect(!APIClientError.unauthorized.message.isEmpty)
}
// MARK: -
@Test("APIClient 的 description/debugDescription 不含令牌明文(零日志泄漏)")
func clientDescriptionNeverLeaksTheToken() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: Self.token, http: http)
// Act
let rendered = "\(client)" + String(reflecting: client)
// Assert
#expect(!rendered.contains(Self.token))
#expect(rendered.contains("redacted"))
}
@Test("hasAccessToken 只暴露有无,不暴露值")
func hasAccessTokenExposesPresenceOnly() async throws {
// Arrange + Act
let http = FakeHTTPTransport()
let withToken = try makeClient(token: Self.token, http: http)
let without = try makeClient(token: nil, http: http)
// Assert
#expect(withToken.hasAccessToken)
#expect(!without.hasAccessToken)
}
}

View File

@@ -0,0 +1,435 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// B1 · git ****ios-completion §1.2 = `src/`
/// - `GET /projects/log?path=[&n=]``src/server.ts:1030-1056` `GitLogResult`
/// `src/types.ts:757-772` w6/G4 `unpushed`/`upstream`
/// - `GET /projects/pr?path=``src/server.ts:1058-1076` `PrStatus`
/// `src/types.ts:617-648` git 200, body availability
/// - `GET /projects/worktree/state?path=``src/server.ts:542-562` `WorktreeState`
/// `src/types.ts:392-408``sync`
/// - `GET /sessions``src/server.ts:479-481` `[HistorySession]`
/// `src/http/history.ts:13-19``claude --resume`
///
/// `/projects/*` `path` 400 git 404
/// 500 RO ** Origin**
struct GitPanelReadTests {
private static let base = "http://192.168.1.5:3000"
private static let repoPath = "/Users/dev/web-terminal"
private static let encodedRepoPath = "%2FUsers%2Fdev%2Fweb-terminal"
private struct Fixture {
let http: FakeHTTPTransport
let client: APIClient
}
private func makeFixture() throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let http = FakeHTTPTransport()
return Fixture(http: http, client: APIClient(endpoint: endpoint, http: http))
}
private func routeURL(_ pathAndQuery: String) throws -> URL {
try #require(URL(string: Self.base + pathAndQuery))
}
// MARK: - Origin iff-GRO Origin
@Test("Origin iff-G(RO 侧):log/pr/worktree-state/sessions 四个端点均为 GET 且不带 Origin")
func readOnlyGitEndpointsNeverCarryOrigin() async throws {
// Arrange
let fixture = try makeFixture()
let query = "?path=\(Self.encodedRepoPath)"
await fixture.http.queueSuccess(
url: try routeURL("/projects/log\(query)"), body: Data(#"{"commits":[]}"#.utf8)
)
await fixture.http.queueSuccess(
url: try routeURL("/projects/pr\(query)"), body: Data(#"{"availability":"no-pr"}"#.utf8)
)
await fixture.http.queueSuccess(
url: try routeURL("/projects/worktree/state\(query)"),
body: Data(#"{"path":"\#(Self.repoPath)"}"#.utf8)
)
await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data("[]".utf8))
// Act
_ = try await fixture.client.gitLog(path: Self.repoPath)
_ = try await fixture.client.prStatus(path: Self.repoPath)
_ = try await fixture.client.worktreeState(path: Self.repoPath)
_ = try await fixture.client.claudeSessions()
// Assert
let requests = await fixture.http.recordedRequests
#expect(requests.count == 4)
for request in requests {
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
}
}
// MARK: - /projects/log
@Test("log 的 path 严格 percent-encode(单点);n 缺省则不带 n 参数")
func logPathIsStrictlyEncodedAndNilCountOmitsTheParam() async throws {
// Arrange
let fixture = try makeFixture()
let raw = "/Users/dev/my proj+α"
let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1"
let expected = try routeURL("/projects/log?path=\(encoded)")
await fixture.http.queueSuccess(url: expected, body: Data(#"{"commits":[]}"#.utf8))
// Act
_ = try await fixture.client.gitLog(path: raw, n: nil)
// Assert
#expect(await fixture.http.recordedRequests.first?.url == expected)
}
@Test("log 的 n 客户端夹到 [1,50](镜像 src/http/git-log.ts GIT_LOG_MAX,服务端仍会再夹一次)")
func logCountIsClampedClientSide() async throws {
// Arrange
let fixture = try makeFixture()
let body = Data(#"{"commits":[]}"#.utf8)
let cases: [(Int, Int)] = [(0, 1), (-7, 1), (10, 10), (999, 50)]
for (_, clamped) in cases {
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)&n=\(clamped)"),
body: body
)
}
// Act
for (requested, _) in cases {
_ = try await fixture.client.gitLog(path: Self.repoPath, n: requested)
}
// Assert FakeHTTPTransport URL ,
let queries = await fixture.http.recordedRequests.compactMap(\.url?.query)
#expect(queries == cases.map { "path=\(Self.encodedRepoPath)&n=\($0.1)" })
}
@Test("GitLogResult 全字段解码(hash/at/subject/unpushed + truncated + upstream,w6/G4)")
func gitLogDecodesFullSample() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"commits":[{"hash":"abc1234","at":1720000000000,"subject":"feat: x","unpushed":true},\
{"hash":"def5678","at":1719990000000,"subject":"fix: y"}],\
"truncated":true,"upstream":"origin/develop"}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
)
// Act
let result = try await fixture.client.gitLog(path: Self.repoPath)
// Assert
#expect(result.commits.count == 2)
#expect(result.truncated)
#expect(result.upstream == "origin/develop")
let first = try #require(result.commits.first)
#expect(first.hash == "abc1234")
#expect(first.at == 1_720_000_000_000)
#expect(first.subject == "feat: x")
#expect(first.unpushed == true)
#expect(result.commits.last?.unpushed == nil) // nil( false)
}
@Test("log 容忍:畸形 commit 逐条丢弃、subject 缺省为空、truncated 缺省 false、未知字段忽略")
func gitLogToleratesDegradedShapes() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"commits":[{"hash":"ok1","at":1,"subject":"s"},42,{"at":2},{"hash":"ok2","at":3},\
{"hash":"bad","at":"nope"}],"futureField":{"x":1}}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
)
// Act
let result = try await fixture.client.gitLog(path: Self.repoPath)
// Assert
#expect(result.commits.map(\.hash) == ["ok1", "ok2"])
#expect(result.commits.last?.subject == "")
#expect(!result.truncated)
#expect(result.upstream == nil)
}
@Test("log 400/404/500 → projectPathInvalid/projectNotFound/gitDataUnavailable;空 path 联网前拒")
func gitLogMapsErrorStatuses() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/log?path=\(Self.encodedRepoPath)")
for status in [400, 404, 500] {
await fixture.http.queueSuccess(
url: url, status: status, body: Data(#"{"error":"nope"}"#.utf8)
)
}
// Act + Assert
let expected: [APIClientError] = [
.projectPathInvalid, .projectNotFound, .gitDataUnavailable,
]
for error in expected {
await #expect(throws: error) {
_ = try await fixture.client.gitLog(path: Self.repoPath)
}
#expect(!error.message.isEmpty)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitLog(path: "")
}
#expect(await fixture.http.recordedRequests.count == 3) // path
}
@Test("log 200 但 body 非对象 → invalidResponseBody")
func gitLogRejectsNonObjectBody() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"),
body: Data("[1,2]".utf8)
)
// Act + Assert
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.gitLog(path: Self.repoPath)
}
}
// MARK: - /projects/pr
@Test("PrStatus 全字段解码(availability=ok + checks 嵌套计数)")
func prStatusDecodesFullSample() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"availability":"ok","number":42,"title":"feat: x","url":"https://github.com/a/b/pull/42",\
"state":"open","isDraft":false,"mergeable":"mergeable","headRefName":"feat/x",\
"baseRefName":"develop","checks":{"total":5,"passing":4,"failing":0,"pending":1}}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/pr?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
)
// Act
let pr = try await fixture.client.prStatus(path: Self.repoPath)
// Assert
#expect(pr.availability == .ok)
#expect(pr.number == 42)
#expect(pr.state == "open")
#expect(pr.isDraft == false)
#expect(pr.mergeable == "mergeable")
#expect(pr.headRefName == "feat/x")
#expect(pr.baseRefName == "develop")
#expect(pr.checks == PrCheckSummary(total: 5, passing: 4, failing: 0, pending: 1))
}
@Test("PrStatus :/ availability .error;; nil")
func prStatusDegradesUnknownAvailability() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)")
let bodies = [
#"{"availability":"quantum-ci"}"#, // 未来值 → .error
#"{}"#, // 缺失 → .error
#"{"availability":"not-installed"}"#,
#"{"availability":"unauthenticated"}"#,
#"{"availability":"disabled"}"#,
#"{"availability":"no-pr"}"#,
]
for body in bodies {
await fixture.http.queueSuccess(url: url, body: Data(body.utf8))
}
// Act
var seen: [PrAvailability] = []
for _ in bodies {
seen.append(try await fixture.client.prStatus(path: Self.repoPath).availability)
}
// Assert
#expect(seen == [.error, .error, .notInstalled, .unauthenticated, .disabled, .noPr])
// 非 ok 时兄弟字段一律缺省 → nil(绝不编造 PR 号)
await fixture.http.queueSuccess(url: url, body: Data(#"{"availability":"no-pr"}"#.utf8))
let degraded = try await fixture.client.prStatus(path: Self.repoPath)
#expect(degraded.number == nil)
#expect(degraded.checks == nil)
}
@Test("pr 400/404/500 projectPathInvalid/projectNotFound/gitDataUnavailable; path ")
func prStatusMapsErrorStatuses() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)")
for status in [400, 404, 500] {
await fixture.http.queueSuccess(url: url, status: status)
}
// Act + Assert
for error in [APIClientError.projectPathInvalid, .projectNotFound, .gitDataUnavailable] {
await #expect(throws: error) {
_ = try await fixture.client.prStatus(path: Self.repoPath)
}
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.prStatus(path: "")
}
}
// MARK: - /projects/worktree/state
@Test("WorktreeState ;sync.lastFetchMs fs.stat().mtimeMs ****")
func worktreeStateDecodesFullSampleIncludingFractionalMtime() async throws {
// Arrange — 1785390645813.5327 是真实 stat().mtimeMs 的形状(APFS 纳秒精度)
let fixture = try makeFixture()
let body = """
{"path":"\(Self.repoPath)","branch":"develop","dirtyCount":3,\
"sync":{"upstream":"origin/develop","ahead":2,"behind":1,\
"lastFetchMs":1785390645813.5327,"detached":false}}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"),
body: Data(body.utf8)
)
// Act
let state = try await fixture.client.worktreeState(path: Self.repoPath)
// Assert
#expect(state.path == Self.repoPath)
#expect(state.branch == "develop")
#expect(state.dirtyCount == 3)
let sync = try #require(state.sync)
#expect(sync.upstream == "origin/develop")
#expect(sync.ahead == 2)
#expect(sync.behind == 1)
#expect(sync.detached == false)
let lastFetchMs = try #require(sync.lastFetchMs)
#expect(abs(lastFetchMs - 1_785_390_645_813.5327) < 0.001)
}
@Test("WorktreeState : upstream/detached HEAD/ fetch ")
func worktreeStateDegradesEachFieldIndependently() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"path":"\(Self.repoPath)","sync":{"detached":true},"unknownField":1}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"),
body: Data(body.utf8)
)
// Act
let state = try await fixture.client.worktreeState(path: Self.repoPath)
// Assert
#expect(state.branch == nil)
#expect(state.dirtyCount == nil)
let sync = try #require(state.sync)
#expect(sync.detached == true)
#expect(sync.upstream == nil)
#expect(sync.ahead == nil)
#expect(sync.behind == nil)
#expect(sync.lastFetchMs == nil) // 从未 fetch —— 绝不编造时间戳
}
@Test("worktree/state 400/404/500 projectPathInvalid/worktreeNotFound/gitDataUnavailable; body invalidResponseBody")
func worktreeStateMapsErrorStatuses() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)")
for status in [400, 404, 500] {
await fixture.http.queueSuccess(url: url, status: status)
}
await fixture.http.queueSuccess(url: url, body: Data("7".utf8))
// Act + Assert
for error in [APIClientError.projectPathInvalid, .worktreeNotFound, .gitDataUnavailable] {
await #expect(throws: error) {
_ = try await fixture.client.worktreeState(path: Self.repoPath)
}
#expect(!error.message.isEmpty)
}
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.worktreeState(path: Self.repoPath)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.worktreeState(path: "")
}
}
// MARK: - GET /sessionsclaude --resume 历史)
@Test("HistorySession :mtimeMs fs.stat() ****()")
func claudeSessionsDecodeFractionalMtime() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
[{"id":"0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b","cwd":"/Users/dev/web-terminal",\
"project":"web-terminal","mtimeMs":1785390645813.5327,"preview":"修一下 CJK locale"},\
{"id":"11111111-2222-4333-8444-555555555555","cwd":"","project":"unknown",\
"mtimeMs":1700000000000,"preview":""}]
"""
await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data(body.utf8))
// Act
let sessions = try await fixture.client.claudeSessions()
// Assert
#expect(sessions.count == 2)
let first = try #require(sessions.first)
#expect(first.id == "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b")
#expect(first.cwd == "/Users/dev/web-terminal")
#expect(first.project == "web-terminal")
#expect(abs(first.mtimeMs - 1_785_390_645_813.5327) < 0.001)
#expect(first.preview == " CJK locale")
#expect(sessions.last?.mtimeMs == 1_700_000_000_000) // 整数也要能解
}
@Test("/sessions : body invalidResponseBody")
func claudeSessionsToleratesDegradedShapes() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/sessions")
let degraded = """
[{"id":"keep-me","mtimeMs":1},"nope",{"cwd":"/x"},{"id":"","mtimeMs":2},\
{"id":"also-keep","mtimeMs":3,"extra":true}]
"""
await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8))
await fixture.http.queueSuccess(url: url, body: Data(#"{"error":"x"}"#.utf8))
// Act
let sessions = try await fixture.client.claudeSessions()
// Assert — id 是 `claude --resume <id>` 的实参,缺失/空 ⇒ 该条无用,丢弃
#expect(sessions.map(\.id) == ["keep-me", "also-keep"])
#expect(sessions.first?.cwd == "")
#expect(sessions.first?.project == "")
#expect(sessions.first?.preview == "")
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.claudeSessions()
}
}
@Test("/sessions 200 unexpectedStatus")
func claudeSessionsRejectsBadStatus() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: try routeURL("/sessions"), status: 500)
// Act + Assert
await #expect(throws: APIClientError.unexpectedStatus(500)) {
_ = try await fixture.client.claudeSessions()
}
}
}

View File

@@ -0,0 +1,459 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// B1 · **G** git + worktree + w2
/// ios-completion §1.2 = `src/`
///
/// - `POST /projects/git/stage``src/server.ts:1184-1218`body `{path,files,stage}`
/// - `POST /projects/git/commit``:1219-1255`body `{path,message}`
/// - `POST /projects/git/push``:1256-1289`body `{path}`
/// - `POST /projects/git/fetch``:1290-1319`body `{path}`
/// - `POST /projects/worktree``:1095-1123`body `{path,branch[,base]}`
/// - `DELETE /projects/worktree``:1124-1153`body `{path,worktreePath,force}`
/// - `POST /projects/worktree/prune``:1154-1183`body `{path}`
/// - `POST /live-sessions/:id/queue``:605-643`body `{text[,appendEnter]}`
///
/// ** `Origin`** body **** `error`
/// `src/http/git-ops.ts` / `worktrees.ts`SEC-M10
/// git stderr403 ****Origin ****
/// 403 message
struct GitWriteTests {
private static let base = "http://192.168.1.5:3000"
private static let repoPath = "/Users/dev/web-terminal"
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
private struct Fixture {
let http: FakeHTTPTransport
let endpoint: HostEndpoint
let client: APIClient
}
private func makeFixture(accessToken: String? = nil) throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let http = FakeHTTPTransport()
return Fixture(
http: http, endpoint: endpoint,
client: APIClient(endpoint: endpoint, http: http, accessToken: accessToken)
)
}
private func routeURL(_ path: String) throws -> URL {
try #require(URL(string: Self.base + path))
}
private func bodyObject(_ request: URLRequest) throws -> [String: Any] {
let body = try #require(request.httpBody)
return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
}
// MARK: - Origin iff-GG Origin,)
@Test("Origin iff-G(G 侧):七条 git/worktree 写路由全部带逐字符相等的 Origin + JSON Content-Type")
func everyGitWriteRouteCarriesByteEqualOrigin() async throws {
// Arrange
let fixture = try makeFixture()
let ok = Data(#"{"ok":true}"#.utf8)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
)
// Act
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt"], stage: true)
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "feat: x")
_ = try await fixture.client.gitPush(path: Self.repoPath)
_ = try await fixture.client.gitFetch(path: Self.repoPath)
_ = try await fixture.client.createWorktree(
path: Self.repoPath, branch: "wt-x", base: nil
)
_ = try await fixture.client.removeWorktree(
path: Self.repoPath, worktreePath: "\(Self.repoPath)/.claude/worktrees/x", force: false
)
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
// Assert
let requests = await fixture.http.recordedRequests
#expect(requests.count == 7)
for request in requests {
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
}
}
@Test("配置了访问令牌时,G 写路由同时带 Cookie 与 Origin(令牌不替代 Origin)")
func gitWriteCarriesCookieAlongsideOrigin() async throws {
// Arrange
let token = "s3cret-token_value.~+/="
let fixture = try makeFixture(accessToken: token)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"), body: Data(#"{"ok":true}"#.utf8)
)
// Act
_ = try await fixture.client.gitPush(path: Self.repoPath)
// Assert
let request = try #require(await fixture.http.recordedRequests.first)
#expect(request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(token)")
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
}
// MARK: - body
@Test("body 形状:stage={path,files,stage} · commit={path,message} · push/fetch/prune={path}")
func gitWriteBodyShapesAreExact() async throws {
// Arrange
let fixture = try makeFixture()
let ok = Data(#"{"ok":true}"#.utf8)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
)
// Act
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt", "b/c.md"], stage: false)
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "fix: 修中文")
_ = try await fixture.client.gitPush(path: Self.repoPath)
_ = try await fixture.client.gitFetch(path: Self.repoPath)
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
// Assert
let requests = await fixture.http.recordedRequests
let stage = try bodyObject(try #require(requests.first))
#expect(Set(stage.keys) == Set(["path", "files", "stage"]))
#expect(stage["path"] as? String == Self.repoPath)
#expect(stage["files"] as? [String] == ["a.txt", "b/c.md"])
#expect(stage["stage"] as? Bool == false)
let commit = try bodyObject(requests[1])
#expect(Set(commit.keys) == Set(["path", "message"]))
#expect(commit["message"] as? String == "fix: 修中文")
for index in 2...4 {
let single = try bodyObject(requests[index])
#expect(Set(single.keys) == Set(["path"]))
#expect(single["path"] as? String == Self.repoPath)
}
}
@Test("worktree body:create 的 base 为 nil 时**不带该键**;remove 恒带 {path,worktreePath,force}")
func worktreeBodyShapesAreExact() async throws {
// Arrange
let fixture = try makeFixture()
let ok = Data(#"{"ok":true}"#.utf8)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
// Act
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-a", base: nil)
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-b", base: "develop")
_ = try await fixture.client.removeWorktree(
path: Self.repoPath, worktreePath: "/tmp/wt", force: true
)
// Assert
let requests = await fixture.http.recordedRequests
let withoutBase = try bodyObject(try #require(requests.first))
#expect(Set(withoutBase.keys) == Set(["path", "branch"]))
#expect(withoutBase["branch"] as? String == "wt-a")
let withBase = try bodyObject(requests[1])
#expect(Set(withBase.keys) == Set(["path", "branch", "base"]))
#expect(withBase["base"] as? String == "develop")
let remove = try bodyObject(requests[2])
#expect(Set(remove.keys) == Set(["path", "worktreePath", "force"]))
#expect(remove["worktreePath"] as? String == "/tmp/wt")
#expect(remove["force"] as? Bool == true)
#expect(requests[2].httpMethod == "DELETE") // DELETE **** JSON body
}
// MARK: - 200 payload body
@Test("200 payload 解码:stage/commit/push/fetch/create/remove/prune 各自形状")
func successPayloadsDecodePerRoute() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/stage"),
body: Data(#"{"ok":true,"staged":true,"count":2}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/commit"),
body: Data(#"{"ok":true,"commit":"abc1234"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"),
body: Data(#"{"ok":true,"branch":"develop","remote":"origin"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/fetch"),
body: Data(#"{"ok":true,"remote":"origin","lastFetchMs":1785390645813.5327}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree"),
body: Data(#"{"ok":true,"path":"/tmp/wt","branch":"worktree-x"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "DELETE", url: try routeURL("/projects/worktree"),
body: Data(#"{"ok":true,"path":"/tmp/wt"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"),
body: Data(#"{"ok":true,"pruned":["wt-a","wt-b"]}"#.utf8)
)
// Act + Assert
#expect(
try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true)
== .ok(StageResult(staged: true, count: 2))
)
#expect(
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
== .ok(CommitResult(commit: "abc1234"))
)
#expect(
try await fixture.client.gitPush(path: Self.repoPath)
== .ok(PushResult(branch: "develop", remote: "origin"))
)
let fetched = try await fixture.client.gitFetch(path: Self.repoPath)
guard case .ok(let fetchResult) = fetched else {
Issue.record("fetch 应为 .ok")
return
}
#expect(fetchResult.remote == "origin")
// lastFetchMs fs.stat().mtimeMs ,
#expect(abs(try #require(fetchResult.lastFetchMs) - 1_785_390_645_813.5327) < 0.001)
#expect(
try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil)
== .ok(CreateWorktreeResult(path: "/tmp/wt", branch: "worktree-x"))
)
#expect(
try await fixture.client.removeWorktree(
path: Self.repoPath, worktreePath: "/tmp/wt", force: false
) == .ok(RemoveWorktreeResult(path: "/tmp/wt"))
)
#expect(
try await fixture.client.pruneWorktrees(path: Self.repoPath)
== .ok(PruneWorktreesResult(pruned: ["wt-a", "wt-b"]))
)
}
@Test("200 但 payload 缺失/畸形 → 降级为默认值(空 sha / 空 pruned),绝不抛")
func garbledSuccessPayloadDegradesToDefaults() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/commit"), body: Data("[]".utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"),
body: Data(#"{"ok":true,"pruned":"not-an-array"}"#.utf8)
)
// Act + Assert
#expect(
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
== .ok(CommitResult(commit: ""))
)
#expect(
try await fixture.client.pruneWorktrees(path: Self.repoPath)
== .ok(PruneWorktreesResult(pruned: []))
)
}
// MARK: - 429 message
@Test("429 → .rateLimited(stage/commit 共用一个限流器,push/fetch 各有更紧的),不得自动重试")
func rateLimitedIsItsOwnOutcome() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"), status: 429,
body: Data(#"{"error":"Too many requests."}"#.utf8)
)
// Act
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
// Assert
#expect(outcome == .rateLimited)
}
@Test("403/409/400/500 → .rejected(status, 服务端已脱敏 message)逐字带出(403 重载:Origin 或开关)")
func failuresCarryTheServerSafeMessageVerbatim() async throws {
// Arrange
let fixture = try makeFixture()
let cases: [(Int, String)] = [
(403, "Git operations are disabled."),
(409, "Nothing staged to commit."),
(400, "Set a git author identity (user.name / user.email) first."),
(500, "Git operation failed."),
]
for (status, message) in cases {
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/commit"), status: status,
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
)
}
// Act + Assert
for (status, message) in cases {
let outcome = try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
#expect(outcome == .rejected(status: status, message: message))
}
}
@Test("失败 body 无法解析 → .rejected(status, nil),不编造原因")
func unparseableFailureBodyYieldsNilMessage() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree"), status: 500,
body: Data("<html>oops</html>".utf8)
)
// Act
let outcome = try await fixture.client.createWorktree(
path: Self.repoPath, branch: "x", base: nil
)
// Assert
#expect(outcome == .rejected(status: 500, message: nil))
}
@Test("push 的 401 是**路由自身**语义('Push authentication required on the host.'),不得当成访问令牌 401")
func push401IsRouteClassifiedNotTheAccessTokenGate() async throws {
// Arrange src/http/git-ops.ts:108 git 401
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
let message = "Push authentication required on the host."
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"), status: 401,
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
)
// Act
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
// Assert gate .unauthorized,
#expect(outcome == .rejected(status: 401, message: message))
}
@Test("空 path 联网前拒(projectPathInvalid),七条写路由一致")
func emptyPathIsRejectedBeforeNetworkOnEveryWriteRoute() async throws {
// Arrange
let fixture = try makeFixture()
// Act + Assert
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitStage(path: "", files: ["a"], stage: true)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitCommit(path: "", message: "m")
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitPush(path: "")
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitFetch(path: "")
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.createWorktree(path: "", branch: "x", base: nil)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.removeWorktree(path: "", worktreePath: "/tmp/wt", force: false)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.pruneWorktrees(path: "")
}
#expect(await fixture.http.recordedRequests.isEmpty)
}
// MARK: - POST /live-sessions/:id/queuew2 pty
@Test("queue 路由:POST /live-sessions/<小写 UUID>/queue,带 Origin,body 恰为 {text,appendEnter}")
func queueRouteShapeIsExact() async throws {
// Arrange
let fixture = try makeFixture()
let id = try #require(UUID(uuidString: Self.sessionIdString))
let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue")
await fixture.http.queueSuccess(
method: "POST", url: url, body: Data(#"{"length":2}"#.utf8)
)
// Act
let depth = try await fixture.client.enqueueFollowup(
sessionId: id, text: "继续", appendEnter: true
)
// Assert
#expect(depth == 2)
let request = try #require(await fixture.http.recordedRequests.first)
#expect(request.httpMethod == "POST")
#expect(request.url == url) // :id
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
let body = try bodyObject(request)
#expect(Set(body.keys) == Set(["text", "appendEnter"]))
#expect(body["text"] as? String == "继续")
#expect(body["appendEnter"] as? Bool == true)
}
@Test("queue 错误映射:400/403/404/409/413/429/503 各自类型化,话术非空")
func queueMapsEveryServerStatusToATypedError() async throws {
// Arrange
let fixture = try makeFixture()
let id = try #require(UUID(uuidString: Self.sessionIdString))
let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue")
let cases: [(Int, APIClientError)] = [
(400, .queueTextInvalid),
(403, .forbidden),
(404, .sessionNotFound),
(409, .queueFull),
(413, .queueTextTooLarge),
(429, .rateLimited),
(503, .queueDisabled),
(500, .unexpectedStatus(500)),
]
for (status, _) in cases {
await fixture.http.queueSuccess(method: "POST", url: url, status: status)
}
// Act + Assert
for (_, expected) in cases {
await #expect(throws: expected) {
_ = try await fixture.client.enqueueFollowup(
sessionId: id, text: "x", appendEnter: false
)
}
#expect(!expected.message.isEmpty)
}
}
@Test("queue 空 text 联网前拒(镜像服务器 400 规则);200 但 body 无 length → invalidResponseBody")
func queueRejectsEmptyTextAndGarbledSuccessBody() async throws {
// Arrange
let fixture = try makeFixture()
let id = try #require(UUID(uuidString: Self.sessionIdString))
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/live-sessions/\(Self.sessionIdString)/queue"),
body: Data(#"{"ok":true}"#.utf8)
)
// Act + Assert
await #expect(throws: APIClientError.queueTextInvalid) {
_ = try await fixture.client.enqueueFollowup(sessionId: id, text: "", appendEnter: true)
}
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.enqueueFollowup(sessionId: id, text: "x", appendEnter: false)
}
}
}

View File

@@ -156,6 +156,67 @@ struct ProjectsTests {
#expect(projects.last?.sessions.isEmpty == true) // sessions []
}
@Test("回归:lastActiveMs 来自 fs.stat().mtimeMs —— **带小数**必须解出来(否则真机上排序键永远为 nil)")
func lastActiveMsDecodesFractionalStatMtime() async throws {
// Arrange 1785390645813.5327(APFS );
// decode(Int.self) , try? nil
let body = """
[{"name":"a","path":"/a","isGit":true,"lastActiveMs":1785390645813.5327,\
"lastCommitMs":1720000000000,"ahead":2,"behind":0,"sessions":[]}]
"""
// Act
let projects = try await fetchProjects(try makeFixture(), body: body)
// Assert
let project = try #require(projects.first)
#expect(project.lastActiveMs == 1_785_390_645_813)
#expect(project.lastCommitMs == 1_720_000_000_000)
#expect(project.ahead == 2)
#expect(project.behind == 0)
}
@Test("session ref 的 cwd(w6/G7)可选解码:有则解出,缺失 → nil")
func sessionRefDecodesOptionalCwd() async throws {
// Arrange
let body = """
[{"name":"a","path":"/a","isGit":true,"sessions":[\
{"id":"\(Self.sessionIdString)","status":"idle","clientCount":0,"createdAt":1,\
"exited":false,"cwd":"/a/.claude/worktrees/x"}]}]
"""
// Act
let projects = try await fetchProjects(try makeFixture(), body: body)
// Assert
#expect(projects.first?.sessions.first?.cwd == "/a/.claude/worktrees/x")
}
@Test("detail 的 dirtyCount / sync(w6/G1)可选解码,与 worktree/state 用同一 SyncState")
func projectDetailDecodesDirtyCountAndSync() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"name":"a","path":"/a","isGit":true,"dirty":true,"dirtyCount":7,\
"sync":{"upstream":"origin/main","ahead":1,"behind":0,"lastFetchMs":1785390645813.5327},\
"worktrees":[],"sessions":[],"hasClaudeMd":false}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/detail?path=%2Fa"), body: Data(body.utf8)
)
// Act
let detail = try await fixture.client.projectDetail(path: "/a")
// Assert
#expect(detail.dirtyCount == 7)
let sync = try #require(detail.sync)
#expect(sync.upstream == "origin/main")
#expect(sync.ahead == 1)
#expect(sync.behind == 0)
#expect(sync.lastFetchMs != nil)
}
@Test("/projects 非数组 body → invalidResponseBody;非 200 → unexpectedStatus")
func projectsRejectsNonArrayBodyAndBadStatus() async throws {
// Act + Assert