test(ios): close T-iOS-32's end-to-end half — real worktree lifecycle against a real server

T-iOS-32's acceptance is "builder 测试 + 端到端一次". The builder half was green,
but grep for projects/worktree across the whole test tree returned nothing — no
test had ever created or removed a real git worktree against a real server.

Six tests on a throwaway git fixture: create/remove/prune verified against both
the filesystem and git's own `worktree list --porcelain` + .git/worktrees/, and
asserting the values the SERVER derives rather than echoing our input (branch
feature/e2e-worktree becomes directory feature-e2e-worktree; the raw branch name
must never appear in the path). Includes a differential leg for the guarded-route
rule — the same request without Origin is 403 with zero side effects, and with it
is 200 — which needs raw URLSession, since APIClient structurally cannot emit an
Origin-less guarded request.

GET /sessions gets its own HOME-isolated server: history.ts reads
os.homedir()/.claude/projects, so against the shared harness the assertions would
land on the developer's real Claude history, and in CI the directory is absent so
it degrades to [] and proves nothing.

Integration tests 26 -> 32, independently re-run.
This commit is contained in:
Yaojia Wang
2026-07-30 16:58:22 +02:00
parent 5cc755b0b6
commit ddab77b337
10 changed files with 1577 additions and 23 deletions

View File

@@ -540,7 +540,7 @@ W5 验收(report-only, 并行)
| T-iOS-13 SessionList | `[x]` | `Screens/SessionListScreen.swift``Components/TelemetryChips.swift``ViewModels/SessionListViewModel.swift` + Tests | | T-iOS-13 SessionList | `[x]` | `Screens/SessionListScreen.swift``Components/TelemetryChips.swift``ViewModels/SessionListViewModel.swift` + Tests |
| T-iOS-14 Gate/Digest UI | `[x]` | `Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift``ViewModels/GateViewModel.swift` + `GateViewModelTests` | | T-iOS-14 Gate/Digest UI | `[x]` | `Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift``ViewModels/GateViewModel.swift` + `GateViewModelTests` |
| T-iOS-15 App 接线+生命周期 | `[x]` | `Wiring/{RootView,AppCoordinator,PrivacyShade,ColdStartPolicy,TerminalContainerView}.swift` + `PrivacyShadeTests`/`ColdStartPolicyTests` | | T-iOS-15 App 接线+生命周期 | `[x]` | `Wiring/{RootView,AppCoordinator,PrivacyShade,ColdStartPolicy,TerminalContainerView}.swift` + `PrivacyShadeTests`/`ColdStartPolicyTests` |
| T-iOS-16 集成 CI | `[x]` | `IntegrationTests/**` **26** `@Test`10 基线 + 8 令牌端到端 + 8 令牌策略漂移守卫)+ `scripts/coverage-gate.sh`(现门 **5** 个包,含 ClientTLS+ `ios.yml` 六个 job含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)+ 新增 `android.yml``ServerHarness.locateTsx` 改为逐级向上解析,故 worktree 内无 `node_modules` 也能自举。**未核实**GH Actions 平台侧的运行结果——本仓库唯一 remote 是自建 Gitea两个 workflow **从未在任何 runner 上跑过**;本地已逐条复跑其命令 | | T-iOS-16 集成 CI | `[x]` | `IntegrationTests/**` **32** `@Test`10 基线 + 8 令牌端到端 + 8 令牌策略漂移守卫 + 6 worktree 生命周期端到端+ `scripts/coverage-gate.sh`(现门 **5** 个包,含 ClientTLS+ `ios.yml` 六个 job含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)+ 新增 `android.yml``ServerHarness.locateTsx` 改为逐级向上解析,故 worktree 内无 `node_modules` 也能自举。**未核实**GH Actions 平台侧的运行结果——本仓库唯一 remote 是自建 Gitea两个 workflow **从未在任何 runner 上跑过**;本地已逐条复跑其命令 |
| T-iOS-17 ntfy 桥验证+文档 | `[x]` | `ios/README.md` ntfy 章节(逐条引 `setup-hooks.mjs` 行号,只读验证,未动用户 hook 配置)。**DEFERRED**:手机端到端 | | T-iOS-17 ntfy 桥验证+文档 | `[x]` | `ios/README.md` ntfy 章节(逐条引 `setup-hooks.mjs` 行号,只读验证,未动用户 hook 配置)。**DEFERRED**:手机端到端 |
| T-iOS-18 F 走查(真机) | `[~]` | 机器可执行项已执行并指认测试名P0 收官条目)。**缺口**F-iOS 的真机项QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端)仍 DEFERRED手工清单在 LOG | | T-iOS-18 F 走查(真机) | `[~]` | 机器可执行项已执行并指认测试名P0 收官条目)。**缺口**F-iOS 的真机项QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端)仍 DEFERRED手工清单在 LOG |
| T-iOS-19 安全核对 | `[~]` | P0 面已逐条核Origin 单点、G/RO 分界、五段 CIDR + 零 ArbitraryLoads、Keychain 属性。P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` **已在产物层复核**(真机构建产物的 Info.plist 实测有这两键)。**剩余缺口**release ipa 层核对仍未做(免费 team 无分发通道) | | T-iOS-19 安全核对 | `[~]` | P0 面已逐条核Origin 单点、G/RO 分界、五段 CIDR + 零 ArbitraryLoads、Keychain 属性。P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` **已在产物层复核**(真机构建产物的 Info.plist 实测有这两键)。**剩余缺口**release ipa 层核对仍未做(免费 team 无分发通道) |
@@ -558,7 +558,7 @@ W5 验收(report-only, 并行)
| T-iOS-29 new-in-cwd + 退出清理 | `[x]` | `TerminalScreen`/`TerminalContainerView` 增量 + `NewSessionInCwdTests` | | T-iOS-29 new-in-cwd + 退出清理 | `[x]` | `TerminalScreen`/`TerminalContainerView` 增量 + `NewSessionInCwdTests` |
| T-iOS-30 P1 验收+安全复核 | `[x]` | report-only 双 PASS 零 findingsLOG 条目)。**DEFERRED**:真机锁屏/unread 目视等,手工清单在 LOG | | T-iOS-30 P1 验收+安全复核 | `[x]` | report-only 双 PASS 零 findingsLOG 条目)。**DEFERRED**:真机锁屏/unread 目视等,手工清单在 LOG |
| T-iOS-31 语音 PTT | `[x]` | `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 🎤 键 + `VoicePTTTests` **40** `@Test`。**DEFERRED**:真机口述→确认→注入 | | T-iOS-31 语音 PTT | `[x]` | `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 🎤 键 + `VoicePTTTests` **40** `@Test`。**DEFERRED**:真机口述→确认→注入 |
| T-iOS-32 Worktree + `--resume` 历史 | `[~]` | `Screens/{WorktreeSheet,ResumeHistorySheet}.swift``ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift` + `WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。**缺口**Accept 的"端到端一次"(对真服务器真建/真删 worktree无自动化腿、本环境也未手工跑 | | T-iOS-32 Worktree + `--resume` 历史 | `[x]` | `Screens/{WorktreeSheet,ResumeHistorySheet}.swift``ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift` + `WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。**Accept 的"端到端一次"已闭合**(收尾波):`IntegrationTests/WorktreeLifecycleTests.swift` **6 例**在临时 git fixture 仓上对真服务器真建/真删/真 prune——断言的是服务端**推导**出的路径与分支(`feature/e2e-worktree` → 目录 `feature-e2e-worktree`,原始分支名绝不出现在路径里),并用 `git worktree list --porcelain``.git/worktrees/` 双向核对;含 G 路由纪律的差分腿(无 `Origin` → 403 且零副作用,同一请求加 `Origin` → 200与非法分支名不创建;`GET /sessions` 用 **HOME 隔离**的专用服务器逐字段解码(否则会打到开发者真实 Claude 历史CI 上又退化成 `[]` 什么都证明不了) |
| T-iOS-33 终端内搜索 | `[x]` | `Components/TerminalSearchBar.swift` + `TerminalScreen` 绑定 SwiftTerm 搜索 API + `TerminalSearchTests` **18**(含 2 条真 view 高亮断言) | | T-iOS-33 终端内搜索 | `[x]` | `Components/TerminalSearchBar.swift` + `TerminalScreen` 绑定 SwiftTerm 搜索 API + `TerminalSearchTests` **18**(含 2 条真 view 高亮断言) |
| T-iOS-34 主题 + Dynamic Type | `[x]` | `DesignSystem/{AppTheme,TerminalPalette}.swift``Screens/SettingsScreen.swift``Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(**14**)/`KeyBarTests`(**12**)。**缺口已闭合**(收尾波):`KeyBarMetrics.barHeight` 由常量 52pt 改为按字号档推导,`withKnownIssue` 已删、换成正向断言AX5 不裁切 · 全 12 档不裁切且 ≥44pt · XSXL 逐点仍 52pt 零回归 · 未封顶 AX5 溢出旧固定高的护栏)。**取舍**:键帽字体封顶在 `DS.Typography.numericClamp`(.accessibility2),故 AX3AX5 的字号不再增长(不封顶时 AX5 要 108.24pt,会吃掉终端);该封顶由测试钉死不得漂移 | | T-iOS-34 主题 + Dynamic Type | `[x]` | `DesignSystem/{AppTheme,TerminalPalette}.swift``Screens/SettingsScreen.swift``Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(**14**)/`KeyBarTests`(**12**)。**缺口已闭合**(收尾波):`KeyBarMetrics.barHeight` 由常量 52pt 改为按字号档推导,`withKnownIssue` 已删、换成正向断言AX5 不裁切 · 全 12 档不裁切且 ≥44pt · XSXL 逐点仍 52pt 零回归 · 未封顶 AX5 溢出旧固定高的护栏)。**取舍**:键帽字体封顶在 `DS.Typography.numericClamp`(.accessibility2),故 AX3AX5 的字号不再增长(不封顶时 AX5 要 108.24pt,会吃掉终端);该封顶由测试钉死不得漂移 |
| T-iOS-35 web `?join=` 互通 | `[x]` | `DeepLinkRouter.swift``.joinShared` 分支 + `DeepLinkJoinTests` **19**(含改写后的既有拒绝用例) | | T-iOS-35 web `?join=` 互通 | `[x]` | `DeepLinkRouter.swift``.joinShared` 分支 + `DeepLinkJoinTests` **19**(含改写后的既有拒绝用例) |
@@ -588,7 +588,7 @@ W5 验收(report-only, 并行)
另补 iPad UI-test 腿(同 commit 另补 iPad UI-test 腿(同 commit
- **签名解锁**`DEVELOPMENT_TEAM` + target 级 `CODE_SIGN_IDENTITY` + `WEBTERM_PUSH_ENTITLEMENTS` env 开关 - **签名解锁**`DEVELOPMENT_TEAM` + target 级 `CODE_SIGN_IDENTITY` + `WEBTERM_PUSH_ENTITLEMENTS` env 开关
commit `c4f8b5b`;真机构建在免费 team 上 `BUILD SUCCEEDED`7 天临时 profile commit `c4f8b5b`;真机构建在免费 team 上 `BUILD SUCCEEDED`7 天临时 profile
- **收尾波2026-07-30闭合了其中三条**① AX5 键栏裁切 → 已修(见 T-iOS-34 行); - **收尾波2026-07-30把四条全部闭合**:④ worktree 端到端 → 见 T-iOS-32 行;① AX5 键栏裁切 → 已修(见 T-iOS-34 行);
② 端到端令牌腿 → `IntegrationTests/AccessTokenGateTests.swift` **8 例**对真服务器(带 `WEBTERM_TOKEN` 自举) ② 端到端令牌腿 → `IntegrationTests/AccessTokenGateTests.swift` **8 例**对真服务器(带 `WEBTERM_TOKEN` 自举)
跑通:对令牌放行 / 错令牌 401 终态且 **connect 计数 == 1**(另有可重试失败的对照组证明"零重试"不是计时假象)/ 跑通:对令牌放行 / 错令牌 401 终态且 **connect 计数 == 1**(另有可重试失败的对照组证明"零重试"不是计时假象)/
令牌不替代 Origin合法 cookie + 外域 Origin 仍拒)/ `POST /auth` 的 204+Set-Cookie、401、204-无-Set-Cookie 三态; 令牌不替代 Origin合法 cookie + 外域 Origin 仍拒)/ `POST /auth` 的 204+Set-Cookie、401、204-无-Set-Cookie 三态;

View File

@@ -38,7 +38,7 @@
|---|---|---| |---|---|---|
| 包测试 | 310 | **452** | | 包测试 | 310 | **452** |
| App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) | | App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) |
| 集成测试 | 10 | **26** | | 集成测试 | 10 | **32** |
| Android 测试 | 687 | **691** | | Android 测试 | 687 | **691** |
| ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) | | ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) |
| 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) | | 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) |

View File

@@ -0,0 +1,25 @@
//
// APIClientTokenPolicyProbe.swift F3APIClient
//
// `APIClient` ********
// `APIClient.AuthCookie` " APIClient "
// import SessionCore
// `AuthCookie` `SessionCore.AuthCookie`
//
// **** import APIClient `AuthCookie` / `AccessTokenRule`
// APIClient internal
// TokenPolicyDriftTests SessionCore `SessionCore.AuthCookie`
@testable import APIClient
enum APIClientTokenPolicy {
static var cookieName: String { AuthCookie.name }
static func headerValue(for token: String) -> String {
AuthCookie.headerValue(for: token)
}
static func isWellFormed(_ candidate: String) -> Bool {
AccessTokenRule.isWellFormed(candidate)
}
}

View File

@@ -0,0 +1,352 @@
//
// AccessTokenGateTests.swift F3(1)访 +
//
// **HTTP **APIClient
// **WS upgrade**SessionCore/URLSessionTermTransport fake
// ****`WEBTERM_TOKEN` Node
// ServerHarness.tokenGatedServer() `URLSessionTermTransport` /
// `APIClient` / `SessionEngine`
//
// curl
// src/http/auth.tssrc/server.ts:340-430/auth + authGate
// src/server.ts:1353-1385upgrade Origin cookie
// docs/plans/ios-completion.md §1.1
//
//
// 1. WS + attach RO G HTTP
// 2. upgrade 401 **** `.unauthorized`****
// 10 /
// 3. HTTP 401 `.unauthorized`
// 4. **** cookie + / Origin
//
// `#expect`
// //
import Foundation
import Testing
import WireProtocol
@testable import APIClient
@testable import SessionCore
// HTTP App URLSessionHTTPTransport
/// `HTTPTransport` over URLSessioncookie jar `Cookie`
/// §1.1jar
struct IntegrationHTTPTransport: HTTPTransport {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await cookieFreeSession.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw HarnessError.setup("非 HTTP 响应")
}
return (data, http)
}
}
// ""
actor ConnectCounter {
private(set) var count = 0
func bump() { count += 1 }
}
/// `URLSessionTermTransport` `connect`
///
/// **** `PingableTermTransport` `transport.connect(to:)`
/// 401 ping
struct CountingTermTransport: TermTransport {
let inner: URLSessionTermTransport
let counter: ConnectCounter
func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
await counter.bump()
return try await inner.connect(to: endpoint)
}
}
// events AsyncStream
/// `isDone` ****
func collectEvents(
from engine: SessionEngine, timeout: Duration,
until isDone: @escaping @Sendable ([SessionEvent]) -> Bool
) async -> [SessionEvent] {
let stream = engine.events
let collector = Task { () -> [SessionEvent] in
var seen: [SessionEvent] = []
for await event in stream {
seen.append(event)
if isDone(seen) { return seen }
}
return seen
}
let deadline = Task {
try? await Task.sleep(for: timeout)
collector.cancel()
}
defer { deadline.cancel() }
return await collector.value
}
func makeEngine(
transport: any TermTransport, endpoint: HostEndpoint
) -> SessionEngine {
SessionEngine(
transport: transport, clock: ContinuousClock(), endpoint: endpoint,
eventsSource: { _ in [] }
)
}
//
@Suite("F3 访问令牌端到端(真令牌网关服务器)", .serialized, .timeLimit(.minutes(10)))
struct AccessTokenGateTests {
// MARK: 1. WS + HTTP
@Test("正确令牌:WS 升级成功且 attach 成功;RO(GET /live-sessions)与 G(DELETE)均放行")
func correctTokenOpensWebSocketAndBothHTTPFamilies() async throws {
// Arrange
let gated = try await ServerHarness.shared.tokenGatedServer()
let token = gated.token
let transport = URLSessionTermTransport(tokenProvider: { token })
let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint)
let client = APIClient(
endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(),
accessToken: token
)
// Act: WS attach attached
await engine.open(sessionId: nil, cwd: nil)
let events = await collectEvents(from: engine, timeout: HarnessTunables.markerTimeout) {
$0.contains { if case .adopted = $0 { return true } else { return false } }
}
let adopted = events.compactMap { event -> UUID? in
if case let .adopted(id) = event { return id } else { return nil }
}.first
// Assert: + attach
let sessionId = try #require(
adopted, "带正确令牌的 WS 升级/attach 应成功,实际事件: \(events)")
#expect(events.contains(.connection(.connected)),
"应观察到 .connected,实际事件: \(events)")
#expect(events.contains(.connection(.failed(.unauthorized))) == false,
"正确令牌不该出现 .unauthorized")
// Assert: RO Origin cookie
let live = try await client.liveSessions()
#expect(live.contains { $0.id == sessionId },
"RO GET /live-sessions 应含刚 attach 的会话 id")
// Assert: G Origin + cookie 204
await engine.close()
try await client.killSession(id: sessionId)
let afterKill = try await client.liveSessions()
#expect(afterKill.contains { $0.id == sessionId } == false,
"G DELETE /live-sessions/:id 应已回收该会话")
}
// MARK: 2. 401 +
@Test("错误令牌:WS 升级 401 → 终态 .unauthorized,且**零**重连尝试(只连了 1 次)")
func wrongTokenIsTerminalAndNeverRetries() async throws {
// Arrange: ,
//
let gated = try await ServerHarness.shared.tokenGatedServer()
let wrong = gated.wrongButWellFormedToken
let counter = ConnectCounter()
let transport = CountingTermTransport(
inner: URLSessionTermTransport(tokenProvider: { wrong }), counter: counter
)
let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint)
// Act
await engine.open(sessionId: nil, cwd: nil)
let events = await collectEvents(from: engine, timeout: HarnessTunables.frameTimeout) {
$0.contains(.connection(.failed(.unauthorized)))
}
// Assert:
#expect(events.contains(.connection(.failed(.unauthorized))),
"错误令牌应得终态 .unauthorized,实际事件: \(events)")
// Assert: 退ReconnectMachine 1s
// "
// 10 /"
try await Task.sleep(for: HarnessTunables.retryObservationWindow)
let attempts = await counter.count
#expect(attempts == 1, "应只连一次(零重连),实际 connect 次数 = \(attempts)")
#expect(events.contains { if case .connection(.reconnecting) = $0 { return true } else { return false } } == false,
"401 不得进入退避环(.reconnecting),实际事件: \(events)")
await engine.close()
}
/// connect == 1/
/// `CountingTermTransport` + ****
/// ECONNREFUSED 2 `.reconnecting`
@Test("对照组:可重试失败(端口无人监听)在同一观察窗口里确实重连 ≥2 次")
func retryableFailureDoesRetryWithinTheSameWindow() async throws {
// Arrange: ****
let port = try findFreeLoopbackPort()
let baseURL = try #require(URL(string: "http://127.0.0.1:\(port)"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let counter = ConnectCounter()
let transport = CountingTermTransport(
inner: URLSessionTermTransport(tokenProvider: { nil }), counter: counter
)
let engine = makeEngine(transport: transport, endpoint: endpoint)
// Act
await engine.open(sessionId: nil, cwd: nil)
let events = await collectEvents(
from: engine, timeout: HarnessTunables.retryObservationWindow
) { _ in false }
// Assert
let attempts = await counter.count
#expect(attempts >= 2,
"可重试失败应在窗口内重连,实际 connect 次数 = \(attempts);若为 1 说明上一条用例的『零重连』是观察不到而非真的没有")
#expect(events.contains { if case .connection(.reconnecting) = $0 { return true } else { return false } },
"可重试失败应发 .reconnecting,实际事件: \(events)")
#expect(events.contains(.connection(.failed(.unauthorized))) == false,
"连不上 ≠ 未授权:不得报 .unauthorized")
await engine.close()
}
// MARK: 3.
@Test("无令牌打令牌网关:WS 401 → 同一终态零重连;RO HTTP 401 → 类型化 .unauthorized")
func missingTokenAgainstGatedServerIsTerminalToo() async throws {
// Arrange: tokenProvider nil Cookie LAN
let gated = try await ServerHarness.shared.tokenGatedServer()
let counter = ConnectCounter()
let transport = CountingTermTransport(
inner: URLSessionTermTransport(tokenProvider: { nil }), counter: counter
)
let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint)
let clientWithoutToken = APIClient(
endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil
)
// Act
await engine.open(sessionId: nil, cwd: nil)
let events = await collectEvents(from: engine, timeout: HarnessTunables.frameTimeout) {
$0.contains(.connection(.failed(.unauthorized)))
}
// Assert: WS
#expect(events.contains(.connection(.failed(.unauthorized))),
"无令牌应得终态 .unauthorized,实际事件: \(events)")
try await Task.sleep(for: HarnessTunables.retryObservationWindow)
let attempts = await counter.count
#expect(attempts == 1, "无令牌同样零重连,实际 connect 次数 = \(attempts)")
await engine.close()
// Assert: HTTP 401 .unauthorizedUI
await #expect(throws: APIClientError.unauthorized) {
_ = try await clientWithoutToken.liveSessions()
}
}
// MARK: 4. Origin
@Test("令牌是加法不是替代:合法 cookie + 外域/缺失 Origin,WS 仍 401、G 路由仍 403")
func tokenNeverSubstitutesForTheOriginGuard() async throws {
// Arrange
let gated = try await ServerHarness.shared.tokenGatedServer()
let server = gated.server
let token = gated.token
let foreignOrigin = "http://127.0.0.1:\(server.port == HarnessTunables.mismatchedOriginPort ? HarnessTunables.mismatchedOriginPortFallback : HarnessTunables.mismatchedOriginPort)"
let bogusId = UUID().uuidString.lowercased()
// Act + Assert(WS)Origin cookie (src/server.ts:1363-1379)
let foreign = WSTestClient(server: server, origin: foreignOrigin, accessToken: token)
defer { foreign.close() }
let foreignFailure = await foreign.handshakeFailure(timeout: HarnessTunables.frameTimeout)
#expect(foreignFailure != nil, "外域 Origin + 合法 cookie 的升级必须失败")
#expect(foreign.handshakeStatusCode == 401,
"应 401,实际 status=\(String(describing: foreign.handshakeStatusCode))")
let noOrigin = WSTestClient(server: server, origin: nil, accessToken: token)
defer { noOrigin.close() }
let noOriginFailure = await noOrigin.handshakeFailure(timeout: HarnessTunables.frameTimeout)
#expect(noOriginFailure != nil, "缺 Origin + 合法 cookie 的升级必须失败")
#expect(noOrigin.handshakeStatusCode == 401,
"应 401,实际 status=\(String(describing: noOrigin.handshakeStatusCode))")
// Act + Assert(HTTP G)403 Origin , 401
// ""
let path = "live-sessions/\(bogusId)"
let foreignOriginStatus = try await rawStatusCode(
server: server, method: "DELETE", path: path, origin: foreignOrigin,
accessToken: token)
let noOriginStatus = try await rawStatusCode(
server: server, method: "DELETE", path: path, origin: nil, accessToken: token)
let bothStatus = try await rawStatusCode(
server: server, method: "DELETE", path: path, origin: server.origin,
accessToken: token)
let originOnlyStatus = try await rawStatusCode(
server: server, method: "DELETE", path: path, origin: server.origin,
accessToken: nil)
#expect(foreignOriginStatus == 403, "外域 Origin + 合法 cookie 应 403,实际 \(foreignOriginStatus)")
#expect(noOriginStatus == 403, "缺 Origin + 合法 cookie 应 403,实际 \(noOriginStatus)")
// :(id 404)
#expect(bothStatus == 404, "Origin+cookie 都对应 404(id 不存在),实际 \(bothStatus)")
// :Origin 401(), 403
#expect(originOnlyStatus == 401, "有 Origin 无令牌应 401,实际 \(originOnlyStatus)")
}
// MARK: 5. POST /auth
@Test("POST /auth:正确令牌 204+Set-Cookie(.valid);错误 401(.invalidToken)")
func authProbeDistinguishesValidFromInvalid() async throws {
// Arrange
let gated = try await ServerHarness.shared.tokenGatedServer()
// cookie /auth , cookie
let client = APIClient(
endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil
)
// Act + Assert
#expect(try await client.probeAccessToken(gated.token) == .valid,
"正确令牌应得 .valid(204 + Set-Cookie)")
#expect(try await client.probeAccessToken(gated.wrongButWellFormedToken) == .invalidToken,
"错误令牌应得 .invalidToken(401)")
}
@Test("POST /auth:服务器未启用鉴权 → 204 但**无** Set-Cookie ⇒ .authDisabled(绝不当作已认证)")
func authProbeReportsAuthDisabledOnUngatedServer() async throws {
// Arrange: **** WEBTERM_TOKEN ,
let plain = try await ServerHarness.shared.server()
let client = APIClient(
endpoint: plain.endpoint, http: IntegrationHTTPTransport(), accessToken: nil
)
// Act
let result = try await client.probeAccessToken(TokenCorpus.validBase)
// Assert: .authDisabled .valid """"
// UI ,(§1.1)
#expect(result == .authDisabled, "未启用鉴权的服务器应得 .authDisabled,实际 \(result)")
}
@Test("未启用鉴权的服务器:不带令牌的 RO/G 请求与从前**逐字节**一样(零回归)")
func ungatedServerKeepsZeroConfigBehaviour() async throws {
// Arrange
let plain = try await ServerHarness.shared.server()
let bogusId = UUID().uuidString.lowercased()
// Act
let roStatus = try await rawStatusCode(
server: plain, method: "GET", path: "live-sessions", origin: nil, accessToken: nil)
let guardedStatus = try await rawStatusCode(
server: plain, method: "DELETE", path: "live-sessions/\(bogusId)",
origin: plain.origin, accessToken: nil)
// Assert: WEBTERM_TOKEN ,LAN
#expect(roStatus == 200, "未启用鉴权时 RO 应 200,实际 \(roStatus)")
#expect(guardedStatus == 404, "未启用鉴权时 G 应走到业务逻辑(404),实际 \(guardedStatus)")
}
}

View File

@@ -0,0 +1,155 @@
//
// GitFixture.swift T-iOS-32 ** git **
//
// `POST /projects/worktree` ****
// `<dirname(repo)>/<basename(repo)>-worktrees/`
// src/http/worktrees.ts:146-160,224-256
// = / repo ****
// worktree `destroy()`
//
// worktreeBase
// <root>/repo HEAD
// <root>/repo-worktrees/<sanitized> worktree
//
// **git ** `worktree list --porcelain`
// HTTP "" ""
import Darwin
import Foundation
/// `git worktree list --porcelain`
struct RegisteredWorktree: Sendable, Equatable {
let path: String
let branch: String?
/// git ""`prunable <reason>`
let prunable: Bool
}
/// git 退 `HarnessError.setup`
/// ****""
@discardableResult
func runGit(_ arguments: [String], in directory: URL) throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = arguments
process.currentDirectoryURL = directory
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
var environment = ProcessInfo.processInfo.environment
environment["GIT_TERMINAL_PROMPT"] = "0" //
process.environment = environment
try process.run()
// EOF wait
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let text = String(decoding: data, as: UTF8.self)
guard process.terminationStatus == 0 else {
throw HarnessError.setup(
"git \(arguments.joined(separator: " ")) 退出码 \(process.terminationStatus): \(text)")
}
return text
}
/// git + `destroy()` `defer`
struct GitFixture: Sendable {
/// ** symlink**macOS $TMPDIR /var/ /private/var/
/// realpath
let root: URL
/// `<root>/repo`
let repo: URL
/// `worktreeRoot` worktree
/// `<dirname(repo)>/<basename(repo)>-worktrees`src/http/worktrees.ts:151-152
var worktreeBase: URL { root.appending(path: "\(repo.lastPathComponent)-worktrees") }
/// `-c` git gpg
private static let identity = [
"-c", "user.email=fixture@example.invalid",
"-c", "user.name=WebTerm Fixture",
"-c", "commit.gpgsign=false",
]
/// + `git worktree add` unborn HEAD
static func make() throws -> GitFixture {
let manager = FileManager.default
let raw = manager.temporaryDirectory
.appending(path: "webterm-worktree-e2e-\(UUID().uuidString)")
try manager.createDirectory(at: raw, withIntermediateDirectories: true)
// macOS $TMPDIR /var/ /private/var/
// worktree git
let root = URL(fileURLWithPath: canonicalPath(raw.path), isDirectory: true)
let repo = root.appending(path: "repo")
try manager.createDirectory(at: repo, withIntermediateDirectories: true)
try runGit(["-c", "init.defaultBranch=main", "init", "-q"], in: repo)
try runGit(identity + ["commit", "-q", "--allow-empty", "-m", "fixture"], in: repo)
return GitFixture(root: root, repo: repo)
}
/// repo worktree `root`
///
func destroy() {
try? FileManager.default.removeItem(at: root)
}
// git
/// `git worktree list --porcelain` worktree
func registeredWorktrees() throws -> [RegisteredWorktree] {
let porcelain = try runGit(["worktree", "list", "--porcelain"], in: repo)
var out: [RegisteredWorktree] = []
var path: String?
var branch: String?
var prunable = false
func flush() {
guard let path else { return }
out.append(RegisteredWorktree(path: path, branch: branch, prunable: prunable))
(branch, prunable) = (nil, false)
}
for rawLine in porcelain.split(separator: "\n", omittingEmptySubsequences: false) {
let line = String(rawLine)
if line.isEmpty {
flush()
path = nil
continue
}
if line.hasPrefix("worktree ") {
path = String(line.dropFirst("worktree ".count))
} else if line.hasPrefix("branch refs/heads/") {
branch = String(line.dropFirst("branch refs/heads/".count))
} else if line == "prunable" || line.hasPrefix("prunable ") {
prunable = true
}
}
flush()
return out
}
/// symlink
func registeredWorktree(atCanonicalPath target: String) throws -> RegisteredWorktree? {
let wanted = canonicalPath(target)
return try registeredWorktrees().first { canonicalPath($0.path) == wanted }
}
/// `.git/worktrees/` prune
func administrativeEntries() -> [String] {
let dir = repo.appending(path: ".git/worktrees")
let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path)
return (names ?? []).sorted()
}
}
/// = `realpath(3)`****
///
/// `URL.resolvingSymlinksInPath()` RED
/// /private **** /private
/// `/var/folders/` git `worktree list`
/// `/private/var/folders/`****
/// `/private` ""
/// `realpath(3)`
/// ""
func canonicalPath(_ path: String) -> String {
guard let resolved = realpath(path, nil) else { return path }
defer { free(resolved) }
return String(cString: resolved)
}

View File

@@ -4,6 +4,17 @@
// ephemeral loopback port; see ServerHarness.swift for the run modes). // ephemeral loopback port; see ServerHarness.swift for the run modes).
// Flat layout (plan §2): test sources live directly in ios/IntegrationTests/. // Flat layout (plan §2): test sources live directly in ios/IntegrationTests/.
// `scripts/` holds the per-package coverage gate used by .github/workflows/ios.yml. // `scripts/` holds the per-package coverage gate used by .github/workflows/ios.yml.
//
// F3 · why this package depends on FOUR client packages while every other
// package depends strictly downward: the access-token policy is duplicated in
// three modules (SessionCore.AuthCookie, HostRegistry.AccessToken,
// APIClient.AccessTokenRule) plus the server's own `WEBTERM_TOKEN_RE`, and the
// orchestrator froze `WireProtocol` (a shared-secret helper does not belong in
// the cross-language wire contract). No single package can therefore see all
// three predicates at once this test target is the ONLY place that can, so
// the anti-drift guard lives here (TokenPolicyDriftTests). The same deps let
// the token end-to-end leg drive the REAL client transport/HTTP layer against a
// REAL token-gated server instead of fakes (AccessTokenGateTests).
import PackageDescription import PackageDescription
let package = Package( let package = Package(
@@ -11,11 +22,19 @@ let package = Package(
platforms: [.iOS(.v17), .macOS(.v14)], platforms: [.iOS(.v17), .macOS(.v14)],
dependencies: [ dependencies: [
.package(path: "../Packages/WireProtocol"), .package(path: "../Packages/WireProtocol"),
.package(path: "../Packages/SessionCore"),
.package(path: "../Packages/HostRegistry"),
.package(path: "../Packages/APIClient"),
], ],
targets: [ targets: [
.testTarget( .testTarget(
name: "IntegrationTests", name: "IntegrationTests",
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")], dependencies: [
.product(name: "WireProtocol", package: "WireProtocol"),
.product(name: "SessionCore", package: "SessionCore"),
.product(name: "HostRegistry", package: "HostRegistry"),
.product(name: "APIClient", package: "APIClient"),
],
path: ".", path: ".",
exclude: ["scripts"] exclude: ["scripts"]
), ),

View File

@@ -19,6 +19,16 @@
// B. WEBTERM_SERVER_URL=http://127.0.0.1:<port> loopback // B. WEBTERM_SERVER_URL=http://127.0.0.1:<port> loopback
// origin // origin
// //
// F3 **** `WEBTERM_TOKEN`
// `ServerHarness.tokenGatedServer()``POST /auth`
// 204 Set-Cookie**** 401/
// ****
// A. §1.1 /**** env
//
// URL
// B. WEBTERM_TOKEN_SERVER_URL + WEBTERM_TOKEN_SERVER_TOKEN
// worktree node_modules checkout
//
// spike WireProtocol T-iOS-3Origin/wsURL // spike WireProtocol T-iOS-3Origin/wsURL
// `HostEndpoint` plan §5.1 // `HostEndpoint` plan §5.1
@@ -51,6 +61,9 @@ enum HarnessTunables {
static let outputAccumulationTimeout: Duration = .seconds(90) static let outputAccumulationTimeout: Duration = .seconds(90)
/// WS kill /// WS kill
static let closeObserveTimeout: Duration = .seconds(30) static let closeObserveTimeout: Duration = .seconds(30)
/// F3 `ReconnectMachine` 退1s
/// """"3.5s 1s + 2s
static let retryObservationWindow: Duration = .milliseconds(3500)
} }
enum HarnessError: Error, CustomStringConvertible { enum HarnessError: Error, CustomStringConvertible {
@@ -65,6 +78,37 @@ enum HarnessError: Error, CustomStringConvertible {
} }
} }
// F3
/// `WEBTERM_TOKEN` +
///
/// §1.1`token` `URLRequest`/`tokenProvider`
/// ****URL query
struct TokenGatedServer: Sendable {
let server: TestServer
let token: String
/// **** 401
/// `AuthCookie.headerValue` nil
///
var wrongButWellFormedToken: String {
// /
let replacement: Character = token.first == "z" ? "y" : "z"
return String(replacement) + token.dropFirst()
}
}
/// 32 §1.1 ASCII
/// TokenPolicyDriftTests
func makeThrowawayToken(length: Int = 32) -> String {
let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
var generator = SystemRandomNumberGenerator()
let characters = (0..<length).map { _ -> Character in
alphabet[Int.random(in: 0..<alphabet.count, using: &generator)]
}
return String(characters)
}
// HostEndpoint // HostEndpoint
struct TestServer: Sendable { struct TestServer: Sendable {
@@ -94,16 +138,21 @@ struct TestServer: Sendable {
private enum ServerProcessRegistry { private enum ServerProcessRegistry {
private static let lock = NSLock() private static let lock = NSLock()
nonisolated(unsafe) private static var process: Process? /// F3 +
/// register deathPipe deinit
/// watchdog EOF
nonisolated(unsafe) private static var processes: [Process] = []
/// deinit watchdog /// deinit watchdog
nonisolated(unsafe) private static var deathPipe: Pipe? nonisolated(unsafe) private static var deathPipes: [Pipe] = []
nonisolated(unsafe) private static var installedAtexit = false nonisolated(unsafe) private static var installedAtexit = false
static func register(_ p: Process) { static func register(_ p: Process) {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
process = p processes.append(p)
deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) if let pipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) {
deathPipes.append(pipe)
}
guard !installedAtexit else { return } guard !installedAtexit else { return }
installedAtexit = true installedAtexit = true
atexit { ServerProcessRegistry.killNow() } atexit { ServerProcessRegistry.killNow() }
@@ -112,8 +161,8 @@ private enum ServerProcessRegistry {
static func killNow() { static func killNow() {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
if let p = process, p.isRunning { p.terminate() } for p in processes where p.isRunning { p.terminate() }
process = nil processes = []
} }
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe { private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
@@ -138,6 +187,7 @@ private enum ServerProcessRegistry {
actor ServerHarness { actor ServerHarness {
static let shared = ServerHarness() static let shared = ServerHarness()
private var bootTask: Task<TestServer, any Error>? private var bootTask: Task<TestServer, any Error>?
private var tokenBootTask: Task<TokenGatedServer, any Error>?
func server() async throws -> TestServer { func server() async throws -> TestServer {
if let bootTask { return try await bootTask.value } if let bootTask { return try await bootTask.value }
@@ -146,19 +196,28 @@ actor ServerHarness {
return try await task.value return try await task.value
} }
/// F3 `WEBTERM_TOKEN` " suite "
func tokenGatedServer() async throws -> TokenGatedServer {
if let tokenBootTask { return try await tokenBootTask.value }
let task = Task { try await Self.bootstrapTokenGated() }
tokenBootTask = task
return try await task.value
}
private static func bootstrap() async throws -> TestServer { private static func bootstrap() async throws -> TestServer {
if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] { if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] {
guard let url = URL(string: external) else { guard let url = URL(string: external) else {
throw HarnessError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)") throw HarnessError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)")
} }
let server = try TestServer.make(baseURL: url) let server = try TestServer.make(baseURL: url)
try await awaitReady(server, process: nil, logURL: nil) try await awaitReady(server, process: nil, logURL: nil, cookie: nil)
return server return server
} }
let spawned = try spawnLocalServer() let spawned = try spawnLocalServer()
ServerProcessRegistry.register(spawned.process) ServerProcessRegistry.register(spawned.process)
do { do {
try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL) try await awaitReady(
spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil)
} catch { } catch {
ServerProcessRegistry.killNow() ServerProcessRegistry.killNow()
throw error throw error
@@ -166,12 +225,71 @@ actor ServerHarness {
return spawned.server return spawned.server
} }
private static func spawnLocalServer() throws -> (server: TestServer, process: Process, logURL: URL) { private static func bootstrapTokenGated() async throws -> TokenGatedServer {
let repoRoot = try locateRepoRoot() let environment = ProcessInfo.processInfo.environment
let tsx = repoRoot.appending(path: "node_modules/.bin/tsx") if let external = environment["WEBTERM_TOKEN_SERVER_URL"] {
guard FileManager.default.isExecutableFile(atPath: tsx.path) else { guard let token = environment["WEBTERM_TOKEN_SERVER_TOKEN"], !token.isEmpty else {
throw HarnessError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install/npm ci") throw HarnessError.setup(
"设了 WEBTERM_TOKEN_SERVER_URL 就必须同时设 WEBTERM_TOKEN_SERVER_TOKEN")
} }
guard let url = URL(string: external) else {
throw HarnessError.setup("WEBTERM_TOKEN_SERVER_URL 不是合法 URL: \(external)")
}
let server = try TestServer.make(baseURL: url)
let gated = TokenGatedServer(server: server, token: token)
// cookie GET /live-sessions cookie 401
try await awaitReady(
server, process: nil, logURL: nil, cookie: authCookieHeader(token: token))
return gated
}
let token = makeThrowawayToken()
let spawned = try spawnLocalServer(extraEnvironment: ["WEBTERM_TOKEN": token])
ServerProcessRegistry.register(spawned.process)
do {
try await awaitReady(
spawned.server, process: spawned.process, logURL: spawned.logURL,
cookie: authCookieHeader(token: token))
} catch {
ServerProcessRegistry.killNow()
throw error
}
return TokenGatedServer(server: spawned.server, token: token)
}
/// T-iOS-32**** bespoke
///
/// `server()``GET /sessions`
/// `os.homedir()/.claude/projects`src/http/history.ts:81
/// `HOME` **** Claude
/// CI `[]`
/// `HOME` env
/// `tokenGatedServer()`
///
/// shared **** `ServerProcessRegistry`
/// death-pipe watchdog tsx
/// `terminate()` `killNow()`
/// suite
///
/// `WEBTERM_SERVER_URL` `HOME`
///
static func disposableServer(extraEnvironment: [String: String]) async throws -> TestServer {
let spawned = try spawnLocalServer(extraEnvironment: extraEnvironment)
ServerProcessRegistry.register(spawned.process)
do {
try await awaitReady(
spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil)
} catch {
spawned.process.terminate()
throw error
}
return spawned.server
}
private static func spawnLocalServer(
extraEnvironment: [String: String] = [:]
) throws -> (server: TestServer, process: Process, logURL: URL) {
let repoRoot = try locateRepoRoot()
let tsx = try locateTsx(from: repoRoot)
let port = try findFreeLoopbackPort() let port = try findFreeLoopbackPort()
guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else { guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else {
throw HarnessError.setup("无法构造 baseURL, port=\(port)") throw HarnessError.setup("无法构造 baseURL, port=\(port)")
@@ -193,6 +311,9 @@ actor ServerHarness {
env["SHELL_PATH"] = "/bin/bash" // zsh env["SHELL_PATH"] = "/bin/bash" // zsh
env["USE_TMUX"] = "0" env["USE_TMUX"] = "0"
env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin" env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
// F3 env `WEBTERM_TOKEN`
// `ps` env 西
for (key, value) in extraEnvironment { env[key] = value }
process.environment = env process.environment = env
process.standardOutput = logHandle process.standardOutput = logHandle
process.standardError = logHandle process.standardError = logHandle
@@ -200,15 +321,20 @@ actor ServerHarness {
return (server, process, logURL) return (server, process, logURL)
} }
private static func awaitReady(_ server: TestServer, process: Process?, logURL: URL?) async throws { /// - Parameter cookie: `Cookie: webterm_auth=`
let probeURL = server.baseURL.appending(path: "live-sessions") /// `GET /live-sessions` 401src/server.ts:459""
private static func awaitReady(
_ server: TestServer, process: Process?, logURL: URL?, cookie: String?
) async throws {
var probe = URLRequest(url: server.baseURL.appending(path: "live-sessions"))
if let cookie { probe.setValue(cookie, forHTTPHeaderField: "Cookie") }
let deadline = ContinuousClock.now + HarnessTunables.serverReadyTimeout let deadline = ContinuousClock.now + HarnessTunables.serverReadyTimeout
while ContinuousClock.now < deadline { while ContinuousClock.now < deadline {
if let process, !process.isRunning { if let process, !process.isRunning {
throw HarnessError.setup( throw HarnessError.setup(
"服务器进程提前退出exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")") "服务器进程提前退出exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")")
} }
if let (data, response) = try? await URLSession.shared.data(from: probeURL), if let (data, response) = try? await cookieFreeSession.data(for: probe),
(response as? HTTPURLResponse)?.statusCode == 200, (response as? HTTPURLResponse)?.statusCode == 200,
(try? JSONSerialization.jsonObject(with: data)) is [Any] { (try? JSONSerialization.jsonObject(with: data)) is [Any] {
return return
@@ -220,6 +346,45 @@ actor ServerHarness {
} }
} }
/// `node_modules/.bin/tsx` repo ****
///
/// `.claude/worktrees/<name>/` worktree
/// `node_modules`Node worktree
/// `src/server.ts` checkout node_modules
/// ""
func locateTsx(from repoRoot: URL) throws -> URL {
var directory = repoRoot.standardizedFileURL
var visited: [String] = []
while true {
let candidate = directory.appending(path: "node_modules/.bin/tsx")
if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate }
visited.append(candidate.path)
let parent = directory.deletingLastPathComponent().standardizedFileURL
if parent.path == directory.path { break }
directory = parent
}
throw HarnessError.setup(
"找不到 node_modules/.bin/tsx逐级向上查过 \(visited.count) 处,"
+ "\(visited.first ?? "?") 起)— 先在 repo 根目录跑 npm install/npm ci")
}
/// `Cookie: webterm_auth=<token>` **** `AUTH_COOKIE_NAME`
/// TokenPolicyDriftTests src/http/auth.ts
func authCookieHeader(token: String) -> String {
"webterm_auth=\(token)"
}
/// cookie jar `Cookie` §1.1
/// `Set-Cookie`
/// `SessionCore.WSConnection.sessionConfiguration`
let cookieFreeSession: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpShouldSetCookies = false
configuration.httpCookieAcceptPolicy = .never
configuration.httpCookieStorage = nil
return URLSession(configuration: configuration)
}()
func locateRepoRoot() throws -> URL { func locateRepoRoot() throws -> URL {
if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] { if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] {
return URL(fileURLWithPath: override, isDirectory: true) return URL(fileURLWithPath: override, isDirectory: true)

View File

@@ -0,0 +1,439 @@
//
// TokenPolicyDriftTests.swift F3(2) +
//
// 访cookie `webterm_auth` `[A-Za-z0-9._~+/=-]`
// 16512 ****
// 1. `SessionCore/AuthCookie.swift` Swift `Regex` WS upgrade
// 2. `HostRegistry/AccessToken.swift` `Set<Character>` + countKeychain
// 3. `APIClient/Endpoints.swift`(AccessToken.swift) `Set<Character>` + countHTTP
// 4. `src/config.ts:178` `WEBTERM_TOKEN_RE`
// 774 ****西****
//
// orchestrator `WireProtocol`
// 线IntegrationTests ****
// Package.swift " = "
//
//
// - Swift + ****
// - `src/config.ts` ****
// Swift
// - cookie `src/http/auth.ts` `AUTH_COOKIE_NAME`
//
// ""
// ****
import Foundation
import HostRegistry
import Testing
@testable import APIClient
@testable import SessionCore
// src/
enum ServerTokenPolicy {
/// `src/config.ts` `const WEBTERM_TOKEN_RE = //`
static func extractedTokenPatternSource() throws -> String {
let source = try readRepoFile("src/config.ts")
guard let line = source.split(separator: "\n").first(where: {
$0.contains("const WEBTERM_TOKEN_RE")
}) else {
throw HarnessError.setup("src/config.ts 里找不到 WEBTERM_TOKEN_RE 定义")
}
guard let open = line.firstIndex(of: "/"), let close = line.lastIndex(of: "/"),
open < close
else {
throw HarnessError.setup("WEBTERM_TOKEN_RE 那一行不是 /…/ 正则字面量: \(line)")
}
return String(line[line.index(after: open)..<close])
}
/// `src/http/auth.ts` `AUTH_COOKIE_NAME = '<name>'`
static func extractedCookieName() throws -> String {
let source = try readRepoFile("src/http/auth.ts")
guard let line = source.split(separator: "\n").first(where: {
$0.contains("export const AUTH_COOKIE_NAME")
}) else {
throw HarnessError.setup("src/http/auth.ts 里找不到 AUTH_COOKIE_NAME 定义")
}
guard let open = line.firstIndex(of: "'"), let close = line.lastIndex(of: "'"),
open < close
else {
throw HarnessError.setup("AUTH_COOKIE_NAME 那一行没有单引号字面量: \(line)")
}
return String(line[line.index(after: open)..<close])
}
/// ****
/// - JS `^`/`$` `m` = **** ICU `\A`/`\z`
/// JS `$` PCRE/ICU "" `$`
/// - `{16,512}` JS **UTF-16 **NSRegularExpression
/// UTF-16 Swift `Character`
/// **** ASCII
static func makeEvaluator() throws -> @Sendable (String) -> Bool {
let jsSource = try extractedTokenPatternSource()
var icuSource = jsSource
guard icuSource.hasPrefix("^"), icuSource.hasSuffix("$") else {
throw HarnessError.setup("WEBTERM_TOKEN_RE 不再是 ^…$ 整串锚定形状: \(jsSource)")
}
icuSource.removeFirst()
icuSource.removeLast()
let regex = try NSRegularExpression(pattern: "\\A\(icuSource)\\z")
return { candidate in
let range = NSRange(candidate.startIndex..., in: candidate)
return regex.firstMatch(in: candidate, options: [], range: range) != nil
}
}
private static func readRepoFile(_ relativePath: String) throws -> String {
let url = try locateRepoRoot().appending(path: relativePath)
guard let text = try? String(contentsOf: url, encoding: .utf8) else {
throw HarnessError.setup("读不到 \(url.path)(服务端真源缺失)")
}
return text
}
}
// +
struct TokenProbe: Sendable {
let label: String
let candidate: String
}
enum TokenCorpus {
/// 16
static let validBase = "AbCdEfGh01234567"
static func repeated(_ unit: String, count: Int) -> String {
String(repeating: unit, count: count)
}
/// emoji
/// U+2013 '-' 15/16/512/513
static let probes: [TokenProbe] = {
var out: [TokenProbe] = []
func add(_ label: String, _ candidate: String) {
out.append(TokenProbe(label: label, candidate: candidate))
}
// ASCII
add("len15(ASCII)", String(repeated("a", count: 15)))
add("len16(ASCII)", String(repeated("a", count: 16)))
add("len512(ASCII)", String(repeated("a", count: 512)))
add("len513(ASCII)", String(repeated("a", count: 513)))
add("len0(empty)", "")
//
add("charset-all-legal", "AZaz09._~+/=-" + "abc")
add("charset-dot-only", String(repeated(".", count: 16)))
add("charset-slash-only", String(repeated("/", count: 16)))
add("charset-equals-only", String(repeated("=", count: 16)))
add("charset-tilde-only", String(repeated("~", count: 16)))
add("charset-plus-only", String(repeated("+", count: 16)))
add("charset-hyphen-only", String(repeated("-", count: 16)))
//
// `_` ****`[A-Za-z0-9._~+/=-]` `.` `_`,
// "",
//
add("underscore(在集合内)", "AbCdEfGh0123456_")
// ""
add("colon", "AbCdEfGh0123456:")
add("semicolon(cookie 分隔符)", "AbCdEfGh0123456;")
add("space-interior", "AbCdEfGh 123456789")
add("percent", "AbCdEfGh0123456%")
add("backslash", "AbCdEfGh0123456\\")
add("comma", "AbCdEfGh0123456,")
add("quote-double", "AbCdEfGh0123456\"")
// CR/LF
add("CR-suffix", validBase + "\r")
add("LF-suffix", validBase + "\n")
add("CRLF-injection", validBase + "\r\nX-Evil: 1")
add("LF-interior", "AbCdEfGh\n123456789")
add("NUL-suffix", validBase + "\u{0}")
add("TAB-suffix", validBase + "\t")
// HostRegistry trim divergence
add("leading-space", " " + validBase)
add("trailing-space", validBase + " ")
add("leading-newline", "\n" + validBase)
// U+2013 EN DASH vs ASCII '-'
add("ascii-hyphen", "AbCdEfGh-1234567")
add("en-dash-U+2013", "AbCdEfGh\u{2013}1234567")
add("minus-sign-U+2212", "AbCdEfGh\u{2212}1234567")
add("non-breaking-hyphen-U+2011", "AbCdEfGh\u{2011}1234567")
// Halfwidth and Fullwidth Forms
add("fullwidth-A(U+FF21)", "\u{FF21}bCdEfGh01234567")
add("fullwidth-digit0(U+FF10)", "\u{FF10}bCdEfGh01234567")
add("fullwidth-fullstop(U+FF0E)", "AbCdEfGh0123456\u{FF0E}")
add("fullwidth-solidus(U+FF0F)", "AbCdEfGh0123456\u{FF0F}")
add("fullwidth-all", String(repeated("\u{FF21}", count: 16)))
// Swift Character=1JS =2
add("combining-acute", "A\u{0301}bCdEfGh01234567")
add("combining-only", String(repeated("a\u{0301}", count: 16)))
// Swift Character = 256 16512 JS UTF-16 = 512
// ""
add("combining-256-graphemes-512-units", String(repeated("a\u{0301}", count: 256)))
add("precomposed-e-acute", "\u{00E9}bCdEfGh01234567")
// /
add("zero-width-space(U+200B)", "AbCdEfGh\u{200B}1234567")
add("zero-width-joiner(U+200D)", "AbCdEfGh\u{200D}1234567")
add("zero-width-nbsp(U+FEFF)", "\u{FEFF}" + validBase)
add("soft-hyphen(U+00AD)", "AbCdEfGh\u{00AD}1234567")
add("nbsp(U+00A0)", "AbCdEfGh\u{00A0}1234567")
add("LTR-override(U+202E)", "AbCdEfGh\u{202E}1234567")
// emoji / Swift 1 CharacterJS 2
add("emoji-single", "AbCdEfGh\u{1F600}1234567")
add("emoji-zwj-family", "AbCdEfG\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}1234567")
add("emoji-flag", "AbCdEfGh\u{1F1E8}\u{1F1F3}123456")
add("emoji-only-16-graphemes", String(repeated("\u{1F600}", count: 16)))
add("emoji-fill-to-512-units", String(repeated("\u{1F600}", count: 256)))
// 西 / homoglyph
add("cyrillic-a(U+0430)", "\u{0430}bCdEfGh01234567")
add("greek-omicron(U+03BF)", "\u{03BF}bCdEfGh01234567")
// ASCII
add("len513-with-fullwidth-tail", String(repeated("a", count: 512)) + "\u{FF21}")
add("len511-plus-emoji", String(repeated("a", count: 511)) + "\u{1F600}")
return out
}()
}
//
struct TokenVerdicts: Equatable {
let sessionCore: Bool
let apiClient: Bool
let hostRegistry: Bool
let server: Bool
/// SessionCore / APIClient / ****
/// HostRegistry trim
/// ""padded
var coreThreeAgree: Bool {
sessionCore == apiClient && apiClient == server
}
var allFourAgree: Bool {
coreThreeAgree && hostRegistry == server
}
var summary: String {
"SessionCore=\(sessionCore) APIClient=\(apiClient) "
+ "HostRegistry=\(hostRegistry) server=\(server)"
}
}
/// `live(server:)`
/// `disagreementLabels` ****
/// Owns
struct TokenPolicySet {
let sessionCore: @Sendable (String) -> Bool
let apiClient: @Sendable (String) -> Bool
let hostRegistry: @Sendable (String) -> Bool
let server: @Sendable (String) -> Bool
/// Swift + src/
static func live(server: @escaping @Sendable (String) -> Bool) -> TokenPolicySet {
TokenPolicySet(
// internal `AuthCookie`SessionCore
// APIClient APIClientTokenPolicy
sessionCore: { SessionCore.AuthCookie.isValidToken($0) },
apiClient: { APIClientTokenPolicy.isWellFormed($0) },
hostRegistry: { AccessToken(rawValue: $0) != nil },
server: server
)
}
func verdicts(for candidate: String) -> TokenVerdicts {
TokenVerdicts(
sessionCore: sessionCore(candidate), apiClient: apiClient(candidate),
hostRegistry: hostRegistry(candidate), server: server(candidate)
)
}
/// ****
func disagreementLabels(over probes: [TokenProbe]) -> [String] {
probes.compactMap { probe in
let verdicts = self.verdicts(for: probe.candidate)
// HostRegistry trim
// trim
let agrees = hasSurroundingWhitespace(probe.candidate)
? verdicts.coreThreeAgree
: verdicts.allFourAgree
return agrees ? nil : "[\(probe.label)] \(verdicts.summary)"
}
}
}
func evaluateTokenPolicies(
_ candidate: String, server: @escaping @Sendable (String) -> Bool
) -> TokenVerdicts {
TokenPolicySet.live(server: server).verdicts(for: candidate)
}
/// HostRegistry trim
func hasSurroundingWhitespace(_ candidate: String) -> Bool {
candidate != candidate.trimmingCharacters(in: .whitespacesAndNewlines)
}
//
@Suite("F3 令牌策略漂移守卫(三份 Swift + 服务端正则)")
struct TokenPolicyDriftTests {
@Test("对抗语料逐条同判:任意两份实现分歧即红")
func allImplementationsAgreeOverAdversarialCorpus() throws {
let serverPredicate = try ServerTokenPolicy.makeEvaluator()
let disagreements = TokenPolicySet.live(server: serverPredicate)
.disagreementLabels(over: TokenCorpus.probes)
#expect(
disagreements.isEmpty,
"""
令牌策略已漂移(\(disagreements.count)/\(TokenCorpus.probes.count) 条样本分歧)。
四份实现必须同判:SessionCore/AuthCookie.swift、HostRegistry/AccessToken.swift、
APIClient/AccessToken.swift、src/config.ts:WEBTERM_TOKEN_RE。
分歧样本(只打标签,不打令牌):
\(disagreements.joined(separator: "\n"))
"""
)
// (""绿)
#expect(TokenCorpus.probes.count >= 50,
"对抗语料被削减到 \(TokenCorpus.probes.count) 条 —— 守卫失去意义")
}
/// ""
/// 使绿
/// SessionCore/HostRegistry/APIClient
/// Owns
@Test("守卫自身是响的:任一份实现哪怕只放宽一个字符,比较逻辑也必须报分歧")
func guardItselfDetectsEachSingleDrift() throws {
let serverPredicate = try ServerTokenPolicy.makeEvaluator()
let live = TokenPolicySet.live(server: serverPredicate)
// `:`**** `[A-Za-z0-9._~+/=-]`
// `_`
//
let drifted: @Sendable (String) -> Bool = { candidate in
let relaxed = candidate.replacingOccurrences(of: ":", with: ".")
return APIClientTokenPolicy.isWellFormed(relaxed)
}
let mutants: [(String, TokenPolicySet)] = [
("SessionCore", TokenPolicySet(
sessionCore: drifted, apiClient: live.apiClient,
hostRegistry: live.hostRegistry, server: live.server)),
("APIClient", TokenPolicySet(
sessionCore: live.sessionCore, apiClient: drifted,
hostRegistry: live.hostRegistry, server: live.server)),
("HostRegistry", TokenPolicySet(
sessionCore: live.sessionCore, apiClient: live.apiClient,
hostRegistry: drifted, server: live.server)),
("server", TokenPolicySet(
sessionCore: live.sessionCore, apiClient: live.apiClient,
hostRegistry: live.hostRegistry, server: drifted)),
]
for (name, mutant) in mutants {
let found = mutant.disagreementLabels(over: TokenCorpus.probes)
#expect(!found.isEmpty, "\(name) 漂移未被守卫抓到 —— 守卫失效(假绿)")
}
// ""
#expect(live.disagreementLabels(over: TokenCorpus.probes).isEmpty)
}
@Test("语料确实两侧都覆盖:既有被四份一致接受的,也有被四份一致拒绝的")
func corpusExercisesBothVerdicts() throws {
let serverPredicate = try ServerTokenPolicy.makeEvaluator()
let verdicts = TokenCorpus.probes.map {
evaluateTokenPolicies($0.candidate, server: serverPredicate).server
}
// "",绿( false ),
// 绿
#expect(verdicts.contains(true), "语料里没有任何被接受的样本 —— 上一条用例会假绿")
#expect(verdicts.contains(false), "语料里没有任何被拒绝的样本 —— 上一条用例会假绿")
}
@Test("服务端正则原文未变:字符集/长度窗口逐字节对钉")
func serverPatternIsStillTheFrozenShape() throws {
let pattern = try ServerTokenPolicy.extractedTokenPatternSource()
// §1.1 Swift
#expect(pattern == "^[A-Za-z0-9._~+/=-]{16,512}$",
"src/config.ts 的 WEBTERM_TOKEN_RE 变了(实测 \(pattern));三份 Swift 实现必须同步更新")
}
@Test("长度窗口 15/16/512/513:四份实现同判且落在 16…512")
func lengthBoundariesAreIdenticalEverywhere() throws {
let serverPredicate = try ServerTokenPolicy.makeEvaluator()
let cases: [(length: Int, expected: Bool)] = [
(15, false), (16, true), (511, true), (512, true), (513, false),
]
for (length, expected) in cases {
let candidate = String(repeating: "a", count: length)
let verdicts = evaluateTokenPolicies(candidate, server: serverPredicate)
#expect(verdicts.allFourAgree, "长度 \(length): \(verdicts.summary)")
#expect(verdicts.server == expected,
"长度 \(length) 应为 \(expected),实测 \(verdicts.summary)")
}
}
@Test("cookie 名三处 + 服务端 AUTH_COOKIE_NAME 全等")
func cookieNameIsPinnedEverywhere() throws {
let serverName = try ServerTokenPolicy.extractedCookieName()
#expect(serverName == "webterm_auth",
"src/http/auth.ts 的 AUTH_COOKIE_NAME 变了(实测 \(serverName))")
#expect(SessionCore.AuthCookie.name == serverName,
"SessionCore.AuthCookie.name=\(SessionCore.AuthCookie.name) ≠ 服务端 \(serverName)")
#expect(APIClientTokenPolicy.cookieName == serverName,
"APIClient.AuthCookie.name=\(APIClientTokenPolicy.cookieName) ≠ 服务端 \(serverName)")
}
@Test("cookie 头拼装形状三处一致:name=<token>,无分号无空格")
func cookieHeaderAssemblyIsIdentical() throws {
let token = TokenCorpus.validBase
let fromSessionCore = SessionCore.AuthCookie.headerValue(token: token)
let fromAPIClient = APIClientTokenPolicy.headerValue(for: token)
#expect(fromSessionCore == "webterm_auth=\(token)")
#expect(fromAPIClient == fromSessionCore,
"两份 cookie 头拼装分歧:APIClient=\(fromAPIClient) SessionCore=\(fromSessionCore ?? "nil")")
// /cookie
#expect(fromAPIClient.contains(";") == false)
#expect(fromAPIClient.contains("\r") == false && fromAPIClient.contains("\n") == false)
}
@Test("已知且有意的分歧:HostRegistry 先 trim 首尾空白,另两份原样拒绝")
func hostRegistryTrimDivergenceIsIntentionalAndBounded() throws {
let serverPredicate = try ServerTokenPolicy.makeEvaluator()
let padded = " \(TokenCorpus.validBase) "
let verdicts = evaluateTokenPolicies(padded, server: serverPredicate)
// , AccessToken ()
// ," trim /"
#expect(verdicts.hostRegistry, "HostRegistry 应接受首尾空白并归一化")
#expect(verdicts.sessionCore == false, "SessionCore 应原样拒绝带空白的串")
#expect(verdicts.apiClient == false, "APIClient 应原样拒绝带空白的串")
#expect(verdicts.server == false, "服务端正则应原样拒绝带空白的串")
// trim ""
let normalized = try #require(AccessToken(rawValue: padded)).rawValue
let afterTrim = evaluateTokenPolicies(normalized, server: serverPredicate)
#expect(afterTrim.allFourAgree && afterTrim.server,
"trim 后的令牌必须四份都接受,实测 \(afterTrim.summary)")
}
}

View File

@@ -21,16 +21,29 @@ final class WSTestClient: @unchecked Sendable {
/// - Parameters: /// - Parameters:
/// - origin: nil = Origin header /// - origin: nil = Origin header
/// `TestServer.origin`HostEndpoint /// `TestServer.origin`HostEndpoint
/// - accessToken: nil = `Cookie`
/// `Cookie: webterm_auth=<t>`§1.1
/// `Set-Cookie` cookie jarF3 " cookie + Origin"
/// ****
/// - maxMessageBytes: `Tunables.maxWSMessageBytes`16 MiB /// - maxMessageBytes: `Tunables.maxWSMessageBytes`16 MiB
/// nil = 1 MiB spike /// nil = 1 MiB spike
init(server: TestServer, origin: String?, maxMessageBytes: Int? = Tunables.maxWSMessageBytes) { init(
server: TestServer, origin: String?, accessToken: String? = nil,
maxMessageBytes: Int? = Tunables.maxWSMessageBytes
) {
var request = URLRequest(url: server.wsURL) var request = URLRequest(url: server.wsURL)
request.timeoutInterval = 15 request.timeoutInterval = 15
if let origin { if let origin {
// T-iOS-2 Origin reserved-header setValue // T-iOS-2 Origin reserved-header setValue
request.setValue(origin, forHTTPHeaderField: "Origin") request.setValue(origin, forHTTPHeaderField: "Origin")
} }
let task = URLSession.shared.webSocketTask(with: request) if let accessToken {
request.setValue(authCookieHeader(token: accessToken), forHTTPHeaderField: "Cookie")
}
// cookie cookie jar
// jar Set-Cookie §1.1
let session = accessToken == nil ? URLSession.shared : cookieFreeSession
let task = session.webSocketTask(with: request)
if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes } if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes }
self.task = task self.task = task
task.resume() task.resume()
@@ -261,6 +274,30 @@ func deleteLiveSession(server: TestServer, id: String, origin: String?) async th
return http.statusCode return http.statusCode
} }
/// F3 + Origin/Cookie HTTP
///
/// `WSTestClient(accessToken:)` APIClient****
/// " cookie + Origin"Origin `HostEndpoint`
/// ""
func rawStatusCode(
server: TestServer, method: String, path: String, origin: String?, accessToken: String?
) async throws -> Int {
var request = URLRequest(url: server.baseURL.appending(path: path))
request.httpMethod = method
// APIClient Accept text/html
// 401 302/loginsrc/server.ts:366-369,459
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") }
if let accessToken {
request.setValue(authCookieHeader(token: accessToken), forHTTPHeaderField: "Cookie")
}
let (_, response) = try await cookieFreeSession.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw HarnessError.setup("\(method) \(path) 非 HTTP 响应")
}
return http.statusCode
}
/// GET /live-sessionsRO Origin id /// GET /live-sessionsRO Origin id
func liveSessionIds(server: TestServer) async throws -> [String] { func liveSessionIds(server: TestServer) async throws -> [String] {
let url = server.baseURL.appending(path: "live-sessions") let url = server.baseURL.appending(path: "live-sessions")

View File

@@ -0,0 +1,362 @@
//
// WorktreeLifecycleTests.swift T-iOS-32 ****
//
// T-iOS-32"worktree + claude --resume "PLAN_IOS_CLIENT.md:917
// builder + builder WorktreeViewModelTests 22 /
// ResumeHistoryViewModelTests 12 / ProjectResumeLaunchTests 7绿
// `grep -rn "projects/worktree" ios/IntegrationTests/` ****
// worktree
//
//
// 1. **** Node ServerHarness+ `APIClient` fake transport
// HTTP
// 2. **** git GitFixture
// `<repo>-worktrees/` ,`destroy()`
// 3. ****HTTP 200 ; **git **
// `worktree list --porcelain` / `.git/worktrees/`****
//
// src/server.ts:1094-1173 + Origin +
// src/http/worktrees.tsvalidate contain execFile shell
//
// **G** Origin 403 CSWSH/CSRF
// 线,****" CI "= CRITICAL
import Foundation
import Testing
import WireProtocol
@testable import APIClient
//
/// `GitWriteOutcome` 200 ; `.ok` ****/,
/// 退
func requireGitWriteOK<Payload>(
_ outcome: GitWriteOutcome<Payload>, _ what: String
) throws -> Payload {
guard case let .ok(payload) = outcome else {
throw HarnessError.setup("\(what) 应为 200 .ok,实际 \(outcome)")
}
return payload
}
/// HTTP + Origin + JSON body (, body)
///
/// `APIClient` ****" Origin G
/// "`APIRoute.originPolicy == .guarded` `HostEndpoint` , §5.1
/// , 403
func rawJSONRequest(
server: TestServer, method: String, path: String, origin: String?, json: [String: Any]
) async throws -> (status: Int, body: [String: Any]) {
var request = URLRequest(url: server.baseURL.appending(path: path))
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Accept text/html:,
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") }
request.httpBody = try JSONSerialization.data(withJSONObject: json)
let (data, response) = try await cookieFreeSession.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw HarnessError.setup("\(method) \(path) 非 HTTP 响应")
}
let parsed = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
return (http.statusCode, parsed ?? [:])
}
//
@Suite("T-iOS-32 worktree 生命周期端到端(真服务器 + 真 APIClient)", .serialized, .timeLimit(.minutes(5)))
struct WorktreeLifecycleTests {
/// `WEBTERM_TOKEN`
private func makeClient(_ server: TestServer) -> APIClient {
APIClient(endpoint: server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil)
}
private let branchWithSlash = "feature/e2e-worktree"
private let sanitizedDirName = "feature-e2e-worktree"
// MARK: 1. create
@Test("create:200 带回**服务端算出来**的 path/branch(目录名被 sanitize),盘上真存在且被 git 登记")
func createProducesARealWorktreeRegisteredWithGit() async throws {
// Arrange
let server = try await ServerHarness.shared.server()
let fixture = try GitFixture.make()
defer { fixture.destroy() }
let client = makeClient(server)
// Act
let outcome = try await client.createWorktree(
path: fixture.repo.path, branch: branchWithSlash, base: nil)
// Assert: git/,
let result = try requireGitWriteOK(outcome, "POST /projects/worktree")
let createdPath = try #require(result.path, "200 必须带回服务端算出的路径")
#expect(result.branch == branchWithSlash,
"branch 应原样回来(它就是 git 建的分支名),实际 \(String(describing: result.branch))")
// : sanitizeBranchForDir ,'/' '-'
// ,
#expect(URL(fileURLWithPath: createdPath).lastPathComponent == sanitizedDirName,
"目录名应是 sanitize 后的 \(sanitizedDirName),实际 \(createdPath)")
#expect(createdPath.hasPrefix(fixture.worktreeBase.path + "/"),
"路径必须落在受控根 \(fixture.worktreeBase.path) 内,实际 \(createdPath)")
#expect(createdPath.contains(branchWithSlash) == false,
"带 '/' 的原始分支名不该出现在路径里,实际 \(createdPath)")
// Assert:HTTP
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: createdPath, isDirectory: &isDirectory)
#expect(exists && isDirectory.boolValue, "worktree 目录应真存在:\(createdPath)")
#expect(FileManager.default.fileExists(atPath: createdPath + "/.git"),
"worktree 里应有 .git 指针文件")
// Assert:git
let registered = try #require(
try fixture.registeredWorktree(atCanonicalPath: createdPath),
"git worktree list 应登记 \(createdPath)")
#expect(registered.branch == branchWithSlash,
"git 登记的分支应是 \(branchWithSlash),实际 \(String(describing: registered.branch))")
let all = try fixture.registeredWorktrees()
#expect(all.count == 2, "应是主 worktree + 新建这一条,实际 \(all)")
#expect(fixture.administrativeEntries() == [sanitizedDirName],
"行政登记 .git/worktrees/ 应只有这一条,实际 \(fixture.administrativeEntries())")
}
// MARK: 2. remove
@Test("remove:200 后目录从盘上消失、从 git 登记消失、行政目录也被清掉;主 worktree 毫发无损")
func removeDeletesTheWorktreeFromDiskAndFromGit() async throws {
// Arrange:, git ""
let server = try await ServerHarness.shared.server()
let fixture = try GitFixture.make()
defer { fixture.destroy() }
let client = makeClient(server)
let created = try requireGitWriteOK(
try await client.createWorktree(
path: fixture.repo.path, branch: branchWithSlash, base: nil),
"前置 create")
let createdPath = try #require(created.path)
let before = try fixture.registeredWorktree(atCanonicalPath: createdPath)
#expect(before != nil, "前置:git 应先登记了它")
// Act: worktree force
let outcome = try await client.removeWorktree(
path: fixture.repo.path, worktreePath: createdPath, force: false)
// Assert: git
let removed = try requireGitWriteOK(outcome, "DELETE /projects/worktree")
let removedPath = try #require(removed.path, "200 必须带回被删的规范路径")
#expect(canonicalPath(removedPath) == canonicalPath(createdPath),
"删掉的应正是刚建的那条,\(removedPath) vs \(createdPath)")
// Assert: + git
#expect(FileManager.default.fileExists(atPath: createdPath) == false,
"目录应已从盘上消失:\(createdPath)")
let afterRemoval = try fixture.registeredWorktree(atCanonicalPath: createdPath)
#expect(afterRemoval == nil, "git worktree list 里不该再有它,实际 \(String(describing: afterRemoval))")
#expect(fixture.administrativeEntries().isEmpty,
"行政登记应一并清掉,实际 \(fixture.administrativeEntries())")
// Assert: worktree destructive
let all = try fixture.registeredWorktrees()
#expect(all.count == 1, "应只剩主 worktree,实际 \(all)")
#expect(canonicalPath(all.first?.path ?? "") == canonicalPath(fixture.repo.path),
"剩下的那条必须是仓库本体")
#expect(FileManager.default.fileExists(atPath: fixture.repo.path + "/.git"))
}
// MARK: 3. prune
@Test("prune:手工删掉 worktree 目录后,陈旧登记被 git 标 prunable;prune 回收它,再 prune 幂等([])")
func pruneReclaimsTheStaleAdministrativeEntry() async throws {
// Arrange
let server = try await ServerHarness.shared.server()
let fixture = try GitFixture.make()
defer { fixture.destroy() }
let client = makeClient(server)
let created = try requireGitWriteOK(
try await client.createWorktree(
path: fixture.repo.path, branch: branchWithSlash, base: nil),
"前置 create")
let createdPath = try #require(created.path)
// Act 1:, prune
try FileManager.default.removeItem(atPath: createdPath)
// Assert:, git prunable"" git ,
let stale = try #require(
try fixture.registeredWorktree(atCanonicalPath: createdPath),
"目录没了但登记应该还在(这才有得 prune)")
#expect(stale.prunable, "git 应把它标成 prunable,实际 \(stale)")
#expect(fixture.administrativeEntries() == [sanitizedDirName],
"行政目录应仍在,实际 \(fixture.administrativeEntries())")
// Act 2
let pruned = try requireGitWriteOK(
try await client.pruneWorktrees(path: fixture.repo.path),
"POST /projects/worktree/prune")
// Assert:,
#expect(pruned.pruned.count == 1, "应回收 1 条,实际 \(pruned.pruned)")
#expect(pruned.pruned.first?.hasSuffix(sanitizedDirName) == true,
"回收标签应指向 \(sanitizedDirName),实际 \(pruned.pruned)")
let afterPrune = try fixture.registeredWorktree(atCanonicalPath: createdPath)
#expect(afterPrune == nil, "陈旧登记应已消失,实际 \(String(describing: afterPrune))")
#expect(fixture.administrativeEntries().isEmpty,
"行政目录应已清掉,实际 \(fixture.administrativeEntries())")
// Assert: , 200 +
let again = try requireGitWriteOK(
try await client.pruneWorktrees(path: fixture.repo.path), "第二次 prune")
#expect(again.pruned.isEmpty, "干净仓库再 prune 应为空,实际 \(again.pruned)")
}
// MARK: 4. G Origin 403,****
@Test("G 守卫端到端:三条 worktree 路由缺 Origin 一律 403 且零副作用;仅补上 Origin 的同一请求 → 200")
func worktreeRoutesRejectRequestsWithoutOrigin() async throws {
// Arrange
let server = try await ServerHarness.shared.server()
let fixture = try GitFixture.make()
defer { fixture.destroy() }
let createBody: [String: Any] = ["path": fixture.repo.path, "branch": branchWithSlash]
// Act + Assert(create): Origin 403,****
let blockedCreate = try await rawJSONRequest(
server: server, method: "POST", path: "projects/worktree", origin: nil, json: createBody)
#expect(blockedCreate.status == 403,
"缺 Origin 的 create 应 403(src/server.ts:1096),实际 \(blockedCreate.status)")
#expect(FileManager.default.fileExists(atPath: fixture.worktreeBase.path) == false,
"被守卫拒掉的请求不该在盘上留下 \(fixture.worktreeBase.path)")
let afterBlockedCreate = try fixture.registeredWorktrees()
#expect(afterBlockedCreate.count == 1,
"被守卫拒掉的请求不该产生任何 git 登记,实际 \(afterBlockedCreate)")
// : Origin
let allowedCreate = try await rawJSONRequest(
server: server, method: "POST", path: "projects/worktree", origin: server.origin,
json: createBody)
#expect(allowedCreate.status == 200,
"带 Origin 的同一请求应 200,实际 \(allowedCreate.status)")
let createdPath = try #require(allowedCreate.body["path"] as? String)
let afterAllowedCreate = try fixture.registeredWorktree(atCanonicalPath: createdPath)
#expect(afterAllowedCreate != nil, "带 Origin 的 create 应真的登记了 \(createdPath)")
// Act + Assert(remove): Origin 403,worktree ****
let removeBody: [String: Any] = [
"path": fixture.repo.path, "worktreePath": createdPath, "force": false,
]
let blockedRemove = try await rawJSONRequest(
server: server, method: "DELETE", path: "projects/worktree", origin: nil,
json: removeBody)
#expect(blockedRemove.status == 403,
"缺 Origin 的 remove 应 403,实际 \(blockedRemove.status)")
#expect(FileManager.default.fileExists(atPath: createdPath),
"被守卫拒掉的 remove 绝不能真的删掉目录")
let afterBlockedRemove = try fixture.registeredWorktree(atCanonicalPath: createdPath)
#expect(afterBlockedRemove != nil, "被守卫拒掉的 remove 不该动 git 登记")
// Act + Assert(prune): Origin 403; Origin 200
let blockedPrune = try await rawJSONRequest(
server: server, method: "POST", path: "projects/worktree/prune", origin: nil,
json: ["path": fixture.repo.path])
#expect(blockedPrune.status == 403,
"缺 Origin 的 prune 应 403,实际 \(blockedPrune.status)")
let allowedPrune = try await rawJSONRequest(
server: server, method: "POST", path: "projects/worktree/prune",
origin: server.origin, json: ["path": fixture.repo.path])
#expect(allowedPrune.status == 200,
"带 Origin 的 prune 应 200,实际 \(allowedPrune.status)")
// : Origin , remove
let allowedRemove = try await rawJSONRequest(
server: server, method: "DELETE", path: "projects/worktree", origin: server.origin,
json: removeBody)
#expect(allowedRemove.status == 200,
"带 Origin 的 remove 应 200,实际 \(allowedRemove.status)")
#expect(FileManager.default.fileExists(atPath: createdPath) == false)
}
// MARK: 5. 400
@Test("非法分支名:400 且**零副作用**(不建目录、不留 git 登记)")
func invalidBranchNamesAreRejectedWithoutCreatingAnything() async throws {
// Arrange: validateBranchName
// src/http/worktrees.ts:95-105:'..' / '-'flag / /
// '.lock' / '/'
let server = try await ServerHarness.shared.server()
let fixture = try GitFixture.make()
defer { fixture.destroy() }
let client = makeClient(server)
let invalidBranches = ["../evil", "-force-flag", "bad name", "ends.lock", "/leading-slash"]
for branch in invalidBranches {
// Act
let outcome = try await client.createWorktree(
path: fixture.repo.path, branch: branch, base: nil)
// Assert: + SAFE git stderr
guard case let .rejected(status, message) = outcome else {
Issue.record("分支 \(branch) 应被拒,实际 \(outcome)")
continue
}
#expect(status == 400, "分支 \(branch) 应 400,实际 \(status)")
#expect(message?.isEmpty == false, "400 应带一句可展示的原因,分支 \(branch)")
}
// Assert:, git
#expect(FileManager.default.fileExists(atPath: fixture.worktreeBase.path) == false,
"非法分支名不该创建受控根 \(fixture.worktreeBase.path)")
let registered = try fixture.registeredWorktrees()
#expect(registered.count == 1, "应仍只有主 worktree,实际 \(registered)")
#expect(fixture.administrativeEntries().isEmpty)
}
// MARK: 6. T-iOS-32 GET /sessionsclaude --resume
@Test("GET /sessions:真 APIClient 对着**HOME 隔离**的真服务器,把 jsonl 解成 HistorySession")
func claudeResumeHistoryDecodesFromTheRealServer() async throws {
// Arrange: HOME `/sessions`
// `os.homedir()/.claude/projects`src/http/history.ts:81,
// CI `[]` ;
//
let manager = FileManager.default
let home = manager.temporaryDirectory
.appending(path: "webterm-history-home-\(UUID().uuidString)")
.resolvingSymlinksInPath()
defer { try? manager.removeItem(at: home) }
let projectDir = home.appending(path: ".claude/projects/-fixture-demo-project")
try manager.createDirectory(at: projectDir, withIntermediateDirectories: true)
let sessionId = UUID().uuidString.lowercased()
let lines: [[String: Any]] = [
["type": "summary", "summary": "不是 user 行,应被跳过"],
[
"type": "user",
"cwd": "/fixture/demo-project",
// content parseSessionMeta
"message": ["content": [["type": "text", "text": "resume me\tplease"]]],
],
]
let jsonl = try lines
.map { String(decoding: try JSONSerialization.data(withJSONObject: $0), as: UTF8.self) }
.joined(separator: "\n")
try jsonl.write(
to: projectDir.appending(path: "\(sessionId).jsonl"), atomically: true, encoding: .utf8)
let server = try await ServerHarness.disposableServer(
extraEnvironment: ["HOME": home.path])
let client = makeClient(server)
// Act:RO APIClient Origin
let sessions = try await client.claudeSessions()
// Assert:HOME
#expect(sessions.count == 1, "隔离 HOME 下应恰好 1 条,实际 \(sessions.count)")
let session = try #require(sessions.first)
#expect(session.id == sessionId, "id 应是 jsonl 的文件名主干(claude --resume 拿它)")
#expect(session.cwd == "/fixture/demo-project")
#expect(session.project == "demo-project", "project = cwd 的末段")
#expect(session.preview == "resume me please", "preview 应折叠空白后截断")
#expect(session.mtimeMs > 0, "mtimeMs 是分数毫秒,必须解出正值")
}
}