fix(ios,android): close the acceptance gaps the review found, add Android CI
- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped — recorded as a withKnownIssue rather than fixed. Height now derives from the content size category (and tracks live changes via registerForTraitChanges); the known-issue marker is gone, replaced by positive assertions including a 12-category no-clip sweep and a guard that stays red if anyone writes the constant back. Honest tradeoff: the keycap font is clamped at .accessibility2, the same policy the design system already applies to dense content, because an unclamped AX5 bar would eat the terminal. A test pins the clamp so the two cannot drift. - Thumbnails silently 401'd on a token-gated host: the pipeline built its own transport with no token source, and by design never throws, so every preview degraded to a placeholder with no signal. Assembled from AppEnvironment now. - Android had zero CI while the token wave shipped 24 files of secret-handling code. The instrumented leg is workflow_dispatch-only and says why in the file: no one has ever seen it green on a runner, and a required leg nobody trusts just produces a false green. - Android persisted a validated token before the host probe succeeded, stranding a secret for a host that never paired. App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691.
This commit is contained in:
@@ -103,26 +103,105 @@ struct DynamicTypeLayoutTests {
|
||||
#expect(size.height.isFinite)
|
||||
}
|
||||
|
||||
// MARK: - F29 · KeyBar(UIKit,固定条高 vs 放大的键帽)—— 已知缺口
|
||||
// MARK: - F29 · KeyBar(UIKit inputAccessoryView):条高跟随键帽
|
||||
|
||||
/// F1 · 这里原是 `withKnownIssue` 记录的**已知缺口**:`KeyBarMetrics.barHeight`
|
||||
/// 固定 52pt(44 + 8),而 AX5 下一枚键帽要 ~108pt(footnote 行高 52.51 +
|
||||
/// caption2 行高 47.73 + 上下 4+4 内衬)——超过一半被裁,T-iOS-34 的验收
|
||||
/// 「最大字号不破版」实际是 FAIL。现已修好:条高由**键帽实际画出来的高度**
|
||||
/// 推出(下限 44pt 命中目标,字号在 DS 的密集内容上限处封顶),所以本组
|
||||
/// 断言全部是真绿,`withKnownIssue` 标记已删除。
|
||||
@Test("KeyBar 在 AX5 下条高容得下它真正画出的键帽(原缺口:裁掉一半以上)")
|
||||
func keyBarFitsItsKeycapsAtLargestType() {
|
||||
let measured = KeyBarProbe.measure(at: KeyBarProbe.largestCategory)
|
||||
|
||||
#expect(
|
||||
measured.keycap <= measured.barHeight + LayoutProbe.epsilon,
|
||||
"AX5 键帽需 \(measured.keycap)pt,条高只有 \(measured.barHeight)pt"
|
||||
)
|
||||
#expect(
|
||||
measured.typographic <= measured.barHeight + LayoutProbe.epsilon,
|
||||
"AX5 排版需求 \(measured.typographic)pt,条高只有 \(measured.barHeight)pt"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("KeyBar 在全部 12 个字号档都不裁切,且条高始终 ≥ 44pt 命中目标")
|
||||
func keyBarFitsItsKeycapsAtEveryContentSize() {
|
||||
for category in KeyBarProbe.allCategories {
|
||||
let measured = KeyBarProbe.measure(at: category)
|
||||
|
||||
/// 量到的事实(iPhone 16 Pro 模拟器):AX5 下一个键帽需要
|
||||
/// **108.24pt**(footnote 行高 52.51 + caption2 行高 47.73 + 上下 4+4 内衬),
|
||||
/// 而 `KeyBarMetrics.barHeight` 是**固定 52pt**(44 + 8)——超过一半被裁。
|
||||
///
|
||||
/// 修法(一行):`barHeight` 改为随 Dynamic Type 缩放,例如
|
||||
/// `UIFontMetrics(forTextStyle: .footnote).scaledValue(for: DS.Layout.minHitTarget + DS.Space.sm8)`,
|
||||
/// 并让 `intrinsicContentSize` 用同一个值。
|
||||
/// **`Components/KeyBar.swift` 不在 C4 的 Owns 里**,故这里只用
|
||||
/// `withKnownIssue` 把缺口钉成机器可见的记录:修好之后本用例会报
|
||||
/// "known issue was not recorded",提醒把这个标记删掉。
|
||||
@Test("KeyBar 键帽在 AX5 下超出固定条高(已知缺口:KeyBar.swift 属他人 Owns)")
|
||||
func keyBarKeycapExceedsBarHeightAtLargestType() {
|
||||
withKnownIssue("KeyBarMetrics.barHeight 固定 52pt,AX5 键帽需约 108pt → 裁切") {
|
||||
let requirement = KeyBarProbe.largestTypeRequirement()
|
||||
#expect(
|
||||
requirement.needed <= requirement.barHeight,
|
||||
"AX5 键帽需 \(requirement.needed)pt,条高只有 \(requirement.barHeight)pt"
|
||||
measured.keycap <= measured.barHeight + LayoutProbe.epsilon,
|
||||
"\(category.rawValue):键帽 \(measured.keycap)pt > 条高 \(measured.barHeight)pt"
|
||||
)
|
||||
#expect(
|
||||
measured.typographic <= measured.barHeight + LayoutProbe.epsilon,
|
||||
"\(category.rawValue):排版需求 \(measured.typographic)pt > 条高 \(measured.barHeight)pt"
|
||||
)
|
||||
#expect(
|
||||
measured.barHeight >= DS.Layout.minHitTarget,
|
||||
"\(category.rawValue):条高 \(measured.barHeight)pt 低于命中目标"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回归护栏 + 缺口的历史事实:**不封顶**的 AX5 排版需求(实测 108.24pt =
|
||||
/// footnote 52.51 + caption2 47.73 + 内衬 8)远超出厂的固定 52pt —— 谁再把
|
||||
/// `barHeight` 写回常量,这条就红。
|
||||
@Test("未封顶的 AX5 排版需求远超出厂 52pt 固定条高(这就是原缺口)")
|
||||
func unclampedLargestTypeOverflowsTheOldFixedBar() {
|
||||
let shipped = DS.Layout.minHitTarget + DS.Space.sm8
|
||||
|
||||
let unclamped = KeyBarProbe.typographicRequirement(at: KeyBarProbe.largestCategory)
|
||||
|
||||
#expect(unclamped > shipped * 2)
|
||||
}
|
||||
|
||||
/// 零回归的准确口径:**键帽还没超过 44pt 命中目标的档位**(XS…XL,含出厂
|
||||
/// 默认 L)条高逐点还是 52pt。再往上(XXL 起)键帽本身就比 44pt 高了,条高
|
||||
/// 必须跟着长——那正是本次修复,不是回归。
|
||||
@Test("默认与更小字号档条高逐点不变(44 + 8 = 52,零回归)")
|
||||
func keyBarKeepsItsShippedHeightAtTheSmallerSizes() {
|
||||
let shipped = DS.Layout.minHitTarget + DS.Space.sm8
|
||||
|
||||
for category in [UIContentSizeCategory.extraSmall, .small, .medium,
|
||||
.large, .extraLarge] {
|
||||
#expect(KeyBarMetrics.barHeight(category) == shipped, "\(category.rawValue)")
|
||||
}
|
||||
#expect(KeyBarProbe.measure(at: .large).barHeight == shipped)
|
||||
}
|
||||
|
||||
@Test("键帽真的随字号长大(不是把字号焊死换来的假绿)")
|
||||
func keyBarActuallyGrowsWithType() {
|
||||
let standard = KeyBarProbe.measure(at: .large)
|
||||
let largest = KeyBarProbe.measure(at: KeyBarProbe.largestCategory)
|
||||
|
||||
#expect(largest.keycap > standard.keycap)
|
||||
#expect(largest.barHeight > standard.barHeight)
|
||||
}
|
||||
|
||||
/// 键栏骑在软键盘上方:长高是必须的,长疯了就把它要驱动的终端吃掉了。
|
||||
/// 故字号在 DS 的密集内容上限(`numericClamp`)处封顶——两处策略必须同源。
|
||||
@Test("字号封顶 == DS 密集内容策略,封顶后条高不再增长且不超出货架高度的两倍")
|
||||
func keyBarTypeCeilingMatchesTheDesignSystemPolicy() {
|
||||
let shipped = DS.Layout.minHitTarget + DS.Space.sm8
|
||||
|
||||
#expect(DynamicTypeSize(KeyBarMetrics.typeCeiling) == DS.Typography.numericClamp)
|
||||
|
||||
let ceiling = KeyBarMetrics.barHeight(KeyBarMetrics.typeCeiling)
|
||||
#expect(KeyBarMetrics.barHeight(KeyBarProbe.largestCategory) == ceiling)
|
||||
// 封顶之前照常长大(不是从第一档就焊死)。
|
||||
#expect(ceiling > KeyBarMetrics.barHeight(.large))
|
||||
// 有界:AX5 的键栏最多是出厂高度的两倍,终端不会被键栏吃掉。
|
||||
#expect(ceiling <= shipped * 2)
|
||||
}
|
||||
|
||||
@Test("frame 高度与 intrinsicContentSize 一致(自适应输入视图靠后者自量)")
|
||||
func keyBarFrameMatchesItsIntrinsicHeight() {
|
||||
for category in [UIContentSizeCategory.large, KeyBarProbe.largestCategory] {
|
||||
let bar = KeyBarView(contentSizeCategory: category)
|
||||
|
||||
#expect(bar.frame.height == bar.intrinsicContentSize.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,30 +227,60 @@ enum LayoutProbe {
|
||||
}
|
||||
}
|
||||
|
||||
/// UIKit 度量:键帽在 AX5 下**需要**多高,对比键栏**给**多高。
|
||||
/// UIKit 度量:把**真** `KeyBarView` 建在某个字号档上,量它**实际画出来**的
|
||||
/// 键帽有多高、它**给出**的条高有多高。两个数都来自同一个真视图,所以
|
||||
/// 「不裁切」是量出来的,不是算出来的。
|
||||
///
|
||||
/// 为什么不直接把 `KeyBarView` 构建在 AX5 trait 里量:`KeyBarView` 的字号来自
|
||||
/// `UIFont.preferredFont(forTextStyle:)`(无 `compatibleWith:`),它读的是
|
||||
/// 为什么不用 `UITraitCollection.performAsCurrent { KeyBarView() }`:
|
||||
/// `UIFont.preferredFont(forTextStyle:)`(**无** `compatibleWith:`)读的是
|
||||
/// **App 级** `preferredContentSizeCategory`,**不看** `UITraitCollection.current`
|
||||
/// —— 所以 `performAsCurrent { KeyBarView() }` 量出来的是默认字号(实测 38pt),
|
||||
/// 是个会"永远通过"的假绿。改为按 `compatibleWith:` 取真实 AX5 行高来算需求,
|
||||
/// 与键帽的两行(glyph + caption)+ 上下内衬一致。
|
||||
/// —— 那样量出来的永远是默认字号(实测 38pt)的**假绿**。修复后键栏把字号档
|
||||
/// 显式注入(`KeyBarView(contentSizeCategory:)`)并全程走 `compatibleWith:`,
|
||||
/// 于是这里量到的就是屏幕上真会画出来的东西。
|
||||
@MainActor
|
||||
enum KeyBarProbe {
|
||||
/// AX5 内容尺寸类别。
|
||||
private static let largestCategory = UIContentSizeCategory
|
||||
static let largestCategory = UIContentSizeCategory
|
||||
.accessibilityExtraExtraExtraLarge
|
||||
|
||||
/// - Returns: (键栏给的高度, AX5 下键帽两行所需高度)
|
||||
static func largestTypeRequirement() -> (barHeight: CGFloat, needed: CGFloat) {
|
||||
let traits = UITraitCollection(preferredContentSizeCategory: largestCategory)
|
||||
/// 全部 12 档(7 标准 + 5 无障碍)——「不破版」是对**每一档**的断言。
|
||||
static let allCategories: [UIContentSizeCategory] = [
|
||||
.extraSmall, .small, .medium, .large, .extraLarge, .extraExtraLarge,
|
||||
.extraExtraExtraLarge, .accessibilityMedium, .accessibilityLarge,
|
||||
.accessibilityExtraLarge, .accessibilityExtraExtraLarge,
|
||||
.accessibilityExtraExtraExtraLarge,
|
||||
]
|
||||
|
||||
/// - Returns:
|
||||
/// - `barHeight` 键栏给出的条高(`intrinsicContentSize`);
|
||||
/// - `keycap` 该档下真键帽**实际**要的高度(从真 `KeyBarView` 的按钮量);
|
||||
/// - `typographic` 同一档的**排版口径**需求 —— 两行行高 + 上下内衬,即原
|
||||
/// 「已知缺口」用例的算法(未封顶的 AX5 实测 108.24pt vs 固定 52pt)。
|
||||
/// 它是 `keycap` 的保守上界(实测在封顶档 68.86 vs 54.67),**两个都**
|
||||
/// 必须被条高盖住 —— 宁可多几点余量,也不能少一点造成裁切。
|
||||
static func measure(
|
||||
at category: UIContentSizeCategory
|
||||
) -> (barHeight: CGFloat, keycap: CGFloat, typographic: CGFloat) {
|
||||
let bar = KeyBarView(contentSizeCategory: category)
|
||||
let keycap = (bar.keyButtons + [bar.voiceButton].compactMap { $0 })
|
||||
.map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height }
|
||||
.max() ?? 0
|
||||
return (
|
||||
barHeight: bar.intrinsicContentSize.height,
|
||||
keycap: keycap,
|
||||
// 只借用「封顶」这一个决定,高度公式本身不复用(否则就是自证)。
|
||||
typographic: typographicRequirement(
|
||||
at: KeyBarMetrics.effectiveCategory(category)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 两行文字 + 键帽上下内衬,逐字沿用缺口用例的算法。
|
||||
static func typographicRequirement(at category: UIContentSizeCategory) -> CGFloat {
|
||||
let traits = UITraitCollection(preferredContentSizeCategory: category)
|
||||
let glyph = UIFont.preferredFont(forTextStyle: .footnote, compatibleWith: traits)
|
||||
let caption = UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits)
|
||||
// KeyBarMetrics.buttonInsets 的上下各 xs4。
|
||||
let verticalInsets = DS.Space.xs4 * 2
|
||||
return (
|
||||
barHeight: KeyBarView().intrinsicContentSize.height,
|
||||
needed: glyph.lineHeight + caption.lineHeight + verticalInsets
|
||||
)
|
||||
return glyph.lineHeight + caption.lineHeight + DS.Space.xs4 * 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,102 @@ struct KeyBarTests {
|
||||
#expect(recorder.keys == KeyBarLayout.buttons.map(\.key))
|
||||
}
|
||||
|
||||
// MARK: - Dynamic Type (T-iOS-34 · the bar tracks the keycap it draws)
|
||||
|
||||
/// Measured height of every keycap, in layout order (the mic key last when
|
||||
/// present) — the only honest way to ask "did the keycaps really re-render".
|
||||
private func keycapHeights(_ bar: KeyBarView) -> [CGFloat] {
|
||||
(bar.keyButtons + [bar.voiceButton].compactMap { $0 })
|
||||
.map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height }
|
||||
}
|
||||
|
||||
@Test("changing the text size re-renders every keycap and re-measures the bar")
|
||||
func contentSizeCategoryChangeRebuildsTheKeycaps() {
|
||||
// Arrange: a bar born at the standard size, mic key included.
|
||||
let bar = KeyBarView(
|
||||
voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}),
|
||||
contentSizeCategory: .large
|
||||
)
|
||||
let standardHeight = bar.intrinsicContentSize.height
|
||||
let standardKeycaps = keycapHeights(bar)
|
||||
|
||||
// Act: the user drags the text-size slider to the maximum.
|
||||
bar.apply(contentSizeCategory: .accessibilityExtraExtraExtraLarge)
|
||||
|
||||
// Assert: EVERY keycap grew (mic included) and the bar grew to hold them.
|
||||
let largestKeycaps = keycapHeights(bar)
|
||||
#expect(bar.contentSizeCategory == .accessibilityExtraExtraExtraLarge)
|
||||
#expect(bar.intrinsicContentSize.height > standardHeight)
|
||||
#expect(bar.frame.height == bar.intrinsicContentSize.height)
|
||||
#expect(standardKeycaps.count == KeyBarLayout.buttons.count + 1)
|
||||
#expect(zip(standardKeycaps, largestKeycaps).allSatisfy { $1 > $0 })
|
||||
#expect(largestKeycaps.allSatisfy { $0 <= bar.intrinsicContentSize.height })
|
||||
}
|
||||
|
||||
/// The runtime path, not just the direct call: the user changes the text size
|
||||
/// in Settings while a terminal is open, UIKit pushes a new trait collection,
|
||||
/// and the bar must re-measure itself — otherwise it keeps whatever height it
|
||||
/// was born with until the terminal is reopened.
|
||||
@Test("a trait-collection change re-lays-out the bar (no reopen needed)")
|
||||
func traitChangePropagatesToTheBar() {
|
||||
// Arrange: a bar inside a real window (trait propagation needs one).
|
||||
let bar = KeyBarView(contentSizeCategory: .large)
|
||||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||
window.addSubview(bar)
|
||||
window.layoutIfNeeded()
|
||||
let before = bar.intrinsicContentSize.height
|
||||
|
||||
// Act: what UIKit does when the system text size changes.
|
||||
window.traitOverrides.preferredContentSizeCategory =
|
||||
.accessibilityExtraExtraExtraLarge
|
||||
window.layoutIfNeeded()
|
||||
|
||||
// Assert
|
||||
#expect(bar.contentSizeCategory == .accessibilityExtraExtraExtraLarge)
|
||||
#expect(bar.intrinsicContentSize.height > before)
|
||||
}
|
||||
|
||||
@Test("re-applying the same text size is a no-op (trait callbacks fire for anything)")
|
||||
func reapplyingTheSameCategoryChangesNothing() {
|
||||
// Arrange
|
||||
let bar = KeyBarView(contentSizeCategory: .large)
|
||||
let before = keycapHeights(bar)
|
||||
let beforeHeight = bar.intrinsicContentSize.height
|
||||
|
||||
// Act
|
||||
bar.apply(contentSizeCategory: .large)
|
||||
|
||||
// Assert
|
||||
#expect(bar.contentSizeCategory == .large)
|
||||
#expect(keycapHeights(bar) == before)
|
||||
#expect(bar.intrinsicContentSize.height == beforeHeight)
|
||||
}
|
||||
|
||||
/// The HIG floor is enforced by a constraint, not by the glyph: at the small
|
||||
/// text sizes a keycap's own content is only ~37pt tall, so without this the
|
||||
/// bar would shrink below a tappable target. Asserted structurally because
|
||||
/// `systemLayoutSizeFitting` reports the CONTENT size (it is what the
|
||||
/// clipping test needs) and would not show the constraint at all.
|
||||
@Test("every keycap carries the 44pt HIG hit-target floor, at every text size")
|
||||
func keycapsCarryTheHitTargetFloor() {
|
||||
for category in [UIContentSizeCategory.extraSmall, .large,
|
||||
.accessibilityExtraExtraExtraLarge] {
|
||||
let bar = KeyBarView(
|
||||
voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}),
|
||||
contentSizeCategory: category
|
||||
)
|
||||
|
||||
for button in bar.keyButtons + [bar.voiceButton].compactMap({ $0 }) {
|
||||
#expect(button.constraints.contains {
|
||||
$0.firstAttribute == .height
|
||||
&& $0.relation == .greaterThanOrEqual
|
||||
&& $0.constant == DS.Layout.minHitTarget
|
||||
})
|
||||
}
|
||||
#expect(bar.intrinsicContentSize.height >= DS.Layout.minHitTarget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
|
||||
|
||||
@Test("hardware chords: Esc, Shift+Tab and the 7 Ctrl chords map through KeyByteMap")
|
||||
|
||||
231
ios/App/WebTermTests/SessionThumbnailTokenTests.swift
Normal file
231
ios/App/WebTermTests/SessionThumbnailTokenTests.swift
Normal file
@@ -0,0 +1,231 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import TestSupport
|
||||
import Testing
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// F1 · 令牌门主机上的缩略图管线。
|
||||
///
|
||||
/// 缺口:`SessionThumbnailPipeline.live()` 自建了一个**没有令牌源**的
|
||||
/// `URLSessionHTTPTransport`,于是在开了 `WEBTERM_TOKEN` 的主机上
|
||||
/// `GET /live-sessions/:id/preview` 一律 401;而本管线按设计**永不抛错**
|
||||
/// (失败即占位图),401 就被静默降级成「每一张缩略图都是占位符」,
|
||||
/// 屏幕上没有任何原因可查。
|
||||
///
|
||||
/// 这里钉三件事:
|
||||
/// 1. 令牌真的到了 preview 请求头上(走的是**生产的**盖章代码路径
|
||||
/// `URLSessionHTTPTransport.authenticated`,不是测试里重写一遍);
|
||||
/// 2. 只读纪律零回归 —— preview 是 RO,**绝不**带 `Origin`(Origin-iff-G);
|
||||
/// 3. 没有令牌 / 令牌不对(401)时依旧**优雅降级**成占位图,不崩不抛。
|
||||
@MainActor
|
||||
@Suite("SessionThumbnail 令牌接线")
|
||||
struct SessionThumbnailTokenTests {
|
||||
/// 32 个合法字符(§1.1 字符集 `[A-Za-z0-9._~+/=-]`,长度 16–512)。
|
||||
private static let token = "0123456789abcdefghijklmnopqrstuv"
|
||||
private static let base = "http://192.168.1.20:3000"
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// 生产的盖章逻辑(`URLSessionHTTPTransport.authenticated`)串在一个可脚本化
|
||||
/// 的记录器前面:真实 Cookie 代码路径,零网络。
|
||||
private struct StampingTransport: HTTPTransport {
|
||||
let stamper: URLSessionHTTPTransport
|
||||
let recorder: FakeHTTPTransport
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
try await recorder.send(await stamper.authenticated(request))
|
||||
}
|
||||
}
|
||||
|
||||
private func makeEndpoint(_ base: String = SessionThumbnailTokenTests.base) throws
|
||||
-> HostEndpoint {
|
||||
let url = try #require(URL(string: base))
|
||||
return try #require(HostEndpoint(baseURL: url))
|
||||
}
|
||||
|
||||
private func makeStore(token: String?) async throws -> InMemoryHostStore {
|
||||
let store = InMemoryHostStore()
|
||||
_ = try await store.upsert(
|
||||
HostRegistry.Host(
|
||||
id: UUID(), name: "mac", endpoint: try makeEndpoint(),
|
||||
accessToken: try token.map { try AccessToken(validating: $0) }
|
||||
)
|
||||
)
|
||||
return store
|
||||
}
|
||||
|
||||
private func previewURL(_ sessionId: UUID) throws -> URL {
|
||||
try #require(
|
||||
URL(string: "\(Self.base)/live-sessions/\(sessionId.uuidString.lowercased())/preview")
|
||||
)
|
||||
}
|
||||
|
||||
private func previewBody(_ sessionId: UUID) throws -> Data {
|
||||
try #require("""
|
||||
{"id":"\(sessionId.uuidString.lowercased())","cols":80,"rows":24,"data":"hello"}
|
||||
""".data(using: .utf8))
|
||||
}
|
||||
|
||||
/// 装配一条**默认生产形状**的管线,只把网络出口换成记录器。
|
||||
private func makePipeline(
|
||||
store: any HostStore
|
||||
) -> (pipeline: SessionThumbnailPipeline, recorder: FakeHTTPTransport) {
|
||||
let recorder = FakeHTTPTransport()
|
||||
let transport = StampingTransport(
|
||||
stamper: AppEnvironment.thumbnailTransport(
|
||||
hostStore: store, identityProvider: { nil }
|
||||
),
|
||||
recorder: recorder
|
||||
)
|
||||
return (SessionThumbnailPipeline.live(http: transport), recorder)
|
||||
}
|
||||
|
||||
// MARK: - 令牌门主机
|
||||
|
||||
@Test("令牌门主机:preview 请求带上 Cookie: webterm_auth=<t>,且缩略图真渲染出来")
|
||||
func previewRequestCarriesTheAccessTokenCookie() async throws {
|
||||
// Arrange
|
||||
let sessionId = UUID()
|
||||
let (pipeline, recorder) = makePipeline(store: try await makeStore(token: Self.token))
|
||||
await recorder.queueSuccess(
|
||||
url: try previewURL(sessionId), body: try previewBody(sessionId)
|
||||
)
|
||||
|
||||
// Act
|
||||
let image = await pipeline.thumbnail(
|
||||
for: SessionThumbnailRequest(
|
||||
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
|
||||
)
|
||||
)
|
||||
|
||||
// Assert
|
||||
let requests = await recorder.recordedRequests
|
||||
#expect(requests.count == 1)
|
||||
#expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header)
|
||||
== "\(AccessTokenCookie.name)=\(Self.token)")
|
||||
#expect(!image.isPlaceholder, "带上令牌后 preview 应当成功并渲染")
|
||||
}
|
||||
|
||||
@Test("preview 是只读路由:带令牌也绝不带 Origin(Origin-iff-G 零回归)")
|
||||
func previewStaysOriginFree() async throws {
|
||||
// Arrange
|
||||
let sessionId = UUID()
|
||||
let (pipeline, recorder) = makePipeline(store: try await makeStore(token: Self.token))
|
||||
await recorder.queueSuccess(
|
||||
url: try previewURL(sessionId), body: try previewBody(sessionId)
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = await pipeline.thumbnail(
|
||||
for: SessionThumbnailRequest(
|
||||
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
|
||||
)
|
||||
)
|
||||
|
||||
// Assert
|
||||
let requests = await recorder.recordedRequests
|
||||
#expect(requests.first?.value(forHTTPHeaderField: "Origin") == nil)
|
||||
}
|
||||
|
||||
// MARK: - 无令牌 / 令牌不对
|
||||
|
||||
@Test("主机没有令牌 → 请求逐字不带 Cookie(LAN 零配置主机零回归)")
|
||||
func tokenlessHostSendsNoCookie() async throws {
|
||||
// Arrange
|
||||
let sessionId = UUID()
|
||||
let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil))
|
||||
await recorder.queueSuccess(
|
||||
url: try previewURL(sessionId), body: try previewBody(sessionId)
|
||||
)
|
||||
|
||||
// Act
|
||||
let image = await pipeline.thumbnail(
|
||||
for: SessionThumbnailRequest(
|
||||
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
|
||||
)
|
||||
)
|
||||
|
||||
// Assert
|
||||
let requests = await recorder.recordedRequests
|
||||
#expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil)
|
||||
#expect(!image.isPlaceholder)
|
||||
}
|
||||
|
||||
@Test("令牌缺失导致 401 → 降级成占位图,不崩不抛(管线的错误 UI 就是占位图)")
|
||||
func unauthorizedDegradesToPlaceholder() async throws {
|
||||
// Arrange:主机记录里没有令牌,服务器要求令牌 → 401。
|
||||
let sessionId = UUID()
|
||||
let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil))
|
||||
await recorder.queueSuccess(
|
||||
url: try previewURL(sessionId), status: 401,
|
||||
body: try #require(#"{"error":"unauthorized"}"#.data(using: .utf8))
|
||||
)
|
||||
|
||||
// Act
|
||||
let image = await pipeline.thumbnail(
|
||||
for: SessionThumbnailRequest(
|
||||
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
|
||||
)
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(image == .placeholder)
|
||||
#expect(await recorder.recordedRequests.count == 1)
|
||||
}
|
||||
|
||||
@Test("主机根本不在存储里(存储读不到该 origin)→ 无 Cookie,仍不崩")
|
||||
func unknownOriginDegradesGracefully() async throws {
|
||||
// Arrange:存储里是另一台主机。
|
||||
let sessionId = UUID()
|
||||
let store = InMemoryHostStore()
|
||||
let otherEndpoint = try makeEndpoint("http://192.168.1.99:3000")
|
||||
_ = try await store.upsert(
|
||||
HostRegistry.Host(
|
||||
id: UUID(), name: "other", endpoint: otherEndpoint,
|
||||
accessToken: try AccessToken(validating: Self.token)
|
||||
)
|
||||
)
|
||||
let (pipeline, recorder) = makePipeline(store: store)
|
||||
await recorder.queueSuccess(
|
||||
url: try previewURL(sessionId), body: try previewBody(sessionId)
|
||||
)
|
||||
|
||||
// Act
|
||||
let image = await pipeline.thumbnail(
|
||||
for: SessionThumbnailRequest(
|
||||
endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1
|
||||
)
|
||||
)
|
||||
|
||||
// Assert:绝不把别台主机的令牌发出去。
|
||||
let requests = await recorder.recordedRequests
|
||||
#expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil)
|
||||
#expect(!image.isPlaceholder)
|
||||
}
|
||||
|
||||
// MARK: - 组合根装配本身
|
||||
|
||||
@Test("thumbnailTransport 就是 App 的同一条盖章传输(按 origin 取本主机令牌)")
|
||||
func thumbnailTransportStampsPerOrigin() async throws {
|
||||
// Arrange
|
||||
let transport = AppEnvironment.thumbnailTransport(
|
||||
hostStore: try await makeStore(token: Self.token), identityProvider: { nil }
|
||||
)
|
||||
let mine = URLRequest(url: try previewURL(UUID()))
|
||||
let other = URLRequest(
|
||||
url: try #require(URL(string: "http://192.168.1.99:3000/live-sessions"))
|
||||
)
|
||||
|
||||
// Act
|
||||
let stamped = await transport.authenticated(mine)
|
||||
let untouched = await transport.authenticated(other)
|
||||
|
||||
// Assert
|
||||
#expect(stamped.value(forHTTPHeaderField: AccessTokenCookie.header)
|
||||
== "\(AccessTokenCookie.name)=\(Self.token)")
|
||||
#expect(untouched.value(forHTTPHeaderField: AccessTokenCookie.header) == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user