|
|
|
|
@@ -0,0 +1,453 @@
|
|
|
|
|
import XCTest
|
|
|
|
|
|
|
|
|
|
/// W5 · The ONE scripted XCUITest happy path (PLAN_IOS_CLIENT §9, fragile-
|
|
|
|
|
/// surface rule): 配对(手输) → 会话列表 → attach → 输入 → gate approve —
|
|
|
|
|
/// against a REAL web-terminal server on loopback.
|
|
|
|
|
///
|
|
|
|
|
/// Contract with the harness:
|
|
|
|
|
/// - The server URL arrives in the RUNNER process env as `WEBTERM_SERVER_URL`
|
|
|
|
|
/// (xcodebuild delivers it via the `TEST_RUNNER_` prefix:
|
|
|
|
|
/// `TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<port>`). Missing env =
|
|
|
|
|
/// explicit FAILURE with instructions, never a silent skip (a green-by-skip
|
|
|
|
|
/// CI leg would be worthless).
|
|
|
|
|
/// - The runner process makes its OWN URLSession calls to the server for
|
|
|
|
|
/// server-side assertions (attach visible in /live-sessions, typed marker in
|
|
|
|
|
/// /live-sessions/:id/preview, held POST /hook/permission resolving to
|
|
|
|
|
/// allow) — XCUITest cannot read SwiftTerm glyphs, the server is the oracle.
|
|
|
|
|
///
|
|
|
|
|
/// Idempotency (documented decision): the app persists hosts in the REAL
|
|
|
|
|
/// Keychain and a UI-test runner cannot uninstall the app, so the flow is
|
|
|
|
|
/// TOLERANT of prior state instead of resetting it:
|
|
|
|
|
/// - fresh install → first-run PairingScreen path;
|
|
|
|
|
/// - host(s) already paired → pair the CURRENT server via the 配对新主机
|
|
|
|
|
/// sheet, then select the just-added host (last menu row with the loopback
|
|
|
|
|
/// label — `HostStore.upsert` appends, so ours is last).
|
|
|
|
|
/// CI simulators are always fresh, so CI exercises the first-run branch; local
|
|
|
|
|
/// re-runs exercise the tolerant branch.
|
|
|
|
|
///
|
|
|
|
|
/// Waits: every UI wait is `waitForExistence` (predicate-based) with a
|
|
|
|
|
/// deadline; server polling is bounded loops with 1 s pauses — no fixed sleep
|
|
|
|
|
/// exceeds 2 s. BUDGET NOTE (measured deviation from the ~120 s aspiration):
|
|
|
|
|
/// SwiftTerm's caret blink keeps Core Animation permanently non-quiescent, so
|
|
|
|
|
/// EVERY XCUITest event on the terminal screen first burns XCUITest's fixed
|
|
|
|
|
/// 60 s "wait for app to idle" timeout ("App animations complete notification
|
|
|
|
|
/// not received" in the log). Interactions there are minimized to three
|
|
|
|
|
/// (focus tap · KeyBar ^L · gate approve) → the whole test runs ~4-5 min.
|
|
|
|
|
///
|
|
|
|
|
/// `@MainActor`: the Xcode 16.3 SDK isolates XCUIApplication/XCUIElement to
|
|
|
|
|
/// the main actor; XCTest runs test methods on the main thread, so class-wide
|
|
|
|
|
/// isolation is the correct spelling under Swift 6 strict concurrency. The
|
|
|
|
|
/// bounded semaphore waits below block only the RUNNER's main thread — the
|
|
|
|
|
/// app under test is a separate process and keeps running.
|
|
|
|
|
@MainActor
|
|
|
|
|
final class HappyPathUITests: XCTestCase {
|
|
|
|
|
|
|
|
|
|
// MARK: - Deadlines (seconds; total budget ~120 s)
|
|
|
|
|
|
|
|
|
|
private enum Deadline {
|
|
|
|
|
static let launchSurface: TimeInterval = 30
|
|
|
|
|
static let ui: TimeInterval = 20
|
|
|
|
|
static let http: TimeInterval = 10
|
|
|
|
|
static let attach: TimeInterval = 30
|
|
|
|
|
static let marker: TimeInterval = 30
|
|
|
|
|
static let gateBanner: TimeInterval = 25
|
|
|
|
|
static let heldDecision: TimeInterval = 30
|
|
|
|
|
static let pollPause: TimeInterval = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private enum TestError: Error {
|
|
|
|
|
case surfaceNotFound
|
|
|
|
|
case pollTimedOut(String)
|
|
|
|
|
case httpFailed(String)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func setUpWithError() throws {
|
|
|
|
|
continueAfterFailure = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - The one happy path
|
|
|
|
|
|
|
|
|
|
func testHappyPath_pairListAttachTypeGateApprove() throws {
|
|
|
|
|
let server = try serverBaseURL()
|
|
|
|
|
|
|
|
|
|
// Precondition: server reachable (fail fast with an actionable message).
|
|
|
|
|
let (probeStatus, _) = try httpGET(server.appendingPathComponent("live-sessions"))
|
|
|
|
|
XCTAssertEqual(
|
|
|
|
|
probeStatus, 200,
|
|
|
|
|
"web-terminal server not reachable at \(server) — start it and pass "
|
|
|
|
|
+ "TEST_RUNNER_WEBTERM_SERVER_URL to xcodebuild"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 1 · Launch and pair (tolerant of pre-existing Keychain hosts).
|
|
|
|
|
let app = XCUIApplication()
|
|
|
|
|
app.launch()
|
|
|
|
|
try pairCurrentServer(app, server: server)
|
|
|
|
|
|
|
|
|
|
// 2 · Session list → + New session.
|
|
|
|
|
let newButton = app.buttons["sessions.newButton"].firstMatch
|
|
|
|
|
XCTAssertTrue(
|
|
|
|
|
newButton.waitForExistence(timeout: Deadline.ui),
|
|
|
|
|
"session list 新建会话 button not found after pairing"
|
|
|
|
|
)
|
|
|
|
|
let sessionsBefore = try liveSessionIds(server)
|
|
|
|
|
newButton.tap()
|
|
|
|
|
|
|
|
|
|
// 3 · attach — server-side oracle: a NEW session with a client attached.
|
|
|
|
|
let sessionId = try pollForAttachedSession(server, excluding: sessionsBefore)
|
|
|
|
|
|
|
|
|
|
// 4 · 输入 — via the KeyBar, asserted server-side.
|
|
|
|
|
//
|
|
|
|
|
// WHY NOT typeText (verified empirically, run 7): XCUITest's typeText
|
|
|
|
|
// requires an AX element with hasKeyboardFocus == 1, and SwiftTerm's
|
|
|
|
|
// TerminalView never exposes one — the runner stalls hunting for
|
|
|
|
|
// focus and no character ever reaches the PTY. The KeyBar is a plain
|
|
|
|
|
// UIButton row (inputAccessoryView) needing no keyboard focus, and it
|
|
|
|
|
// exercises the SAME input pipeline as typed text: KeyByteMap →
|
|
|
|
|
// TerminalViewModel.send(key:) → engine → WS input frame → PTY.
|
|
|
|
|
// Oracle: ^L (0x0C) makes bash-readline answer clear-screen
|
|
|
|
|
// ESC[H ESC[2J — bytes that appear in the ring buffer ONLY when the
|
|
|
|
|
// shell received our input (resize redraws are \r ESC[K, no overlap).
|
|
|
|
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
|
|
|
XCTAssertTrue(
|
|
|
|
|
app.keyboards.firstMatch.waitForExistence(timeout: Deadline.ui),
|
|
|
|
|
"software keyboard (with the KeyBar accessory) never appeared — "
|
|
|
|
|
+ "disable the simulator's hardware keyboard if testing locally"
|
|
|
|
|
)
|
|
|
|
|
let ctrlLButton = app.buttons["Ctrl+L — redraw screen"].firstMatch
|
|
|
|
|
XCTAssertTrue(
|
|
|
|
|
ctrlLButton.waitForExistence(timeout: Deadline.ui),
|
|
|
|
|
"KeyBar ^L button not found above the keyboard"
|
|
|
|
|
)
|
|
|
|
|
ctrlLButton.tap()
|
|
|
|
|
try pollPreview(server, sessionId: sessionId, contains: "\u{1B}[H\u{1B}[2J")
|
|
|
|
|
|
|
|
|
|
// 5 · gate approve — hold a PermissionRequest exactly like a Claude
|
|
|
|
|
// Code hook would (loopback POST, X-Webterm-Session header), approve
|
|
|
|
|
// from the banner, and assert the HELD response resolves to allow.
|
|
|
|
|
let held = startHeldPermissionRequest(server, sessionId: sessionId, tool: "UITestTool")
|
|
|
|
|
let approveButton = app.buttons["gate.decision.approve"].firstMatch
|
|
|
|
|
XCTAssertTrue(
|
|
|
|
|
approveButton.waitForExistence(timeout: Deadline.gateBanner),
|
|
|
|
|
"GateBanner approve button never appeared for the held permission"
|
|
|
|
|
)
|
|
|
|
|
approveButton.tap()
|
|
|
|
|
|
|
|
|
|
guard let result = held.waitForResult(timeout: Deadline.heldDecision) else {
|
|
|
|
|
XCTFail("held POST /hook/permission did not resolve within \(Deadline.heldDecision)s of approving")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
XCTAssertEqual(result.status, 200, "held /hook/permission answered HTTP \(String(describing: result.status))")
|
|
|
|
|
let behavior = Self.decisionBehavior(in: result.data)
|
|
|
|
|
XCTAssertEqual(
|
|
|
|
|
behavior, "allow",
|
|
|
|
|
"held permission resolved to \(behavior ?? "<no decision>") — expected allow "
|
|
|
|
|
+ "(body: \(String(data: result.data ?? Data(), encoding: .utf8) ?? "<non-utf8>"))"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Pairing (tolerant two-branch flow)
|
|
|
|
|
|
|
|
|
|
private func serverBaseURL() throws -> URL {
|
|
|
|
|
let raw = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"]
|
|
|
|
|
guard let raw, let url = URL(string: raw), url.host != nil else {
|
|
|
|
|
XCTFail(
|
|
|
|
|
"WEBTERM_SERVER_URL missing/invalid in the runner env. Run: "
|
|
|
|
|
+ "xcodebuild -scheme WebTermUITests "
|
|
|
|
|
+ "TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<port> test "
|
|
|
|
|
+ "(with the server running on that port)."
|
|
|
|
|
)
|
|
|
|
|
throw TestError.surfaceNotFound
|
|
|
|
|
}
|
|
|
|
|
return url
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Waits for whichever entry surface the persisted state produced, then
|
|
|
|
|
/// drives pairing so the CURRENT server ends up as the ACTIVE host.
|
|
|
|
|
private func pairCurrentServer(_ app: XCUIApplication, server: URL) throws {
|
|
|
|
|
let urlField = app.textFields["pairing.urlField"].firstMatch
|
|
|
|
|
let hostMenu = app.buttons["sessions.hostMenu"].firstMatch
|
|
|
|
|
let emptyAddHost = app.buttons["配对新主机"].firstMatch
|
|
|
|
|
|
|
|
|
|
let deadline = Date().addingTimeInterval(Deadline.launchSurface)
|
|
|
|
|
while Date() < deadline {
|
|
|
|
|
if urlField.exists {
|
|
|
|
|
// Branch A — fresh install: first-run PairingScreen (CI path).
|
|
|
|
|
try fillPairingForm(app, server: server)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if hostMenu.exists || emptyAddHost.exists {
|
|
|
|
|
// Branch B — prior state: add the current server via the sheet.
|
|
|
|
|
if hostMenu.exists {
|
|
|
|
|
hostMenu.tap()
|
|
|
|
|
}
|
|
|
|
|
XCTAssertTrue(
|
|
|
|
|
emptyAddHost.waitForExistence(timeout: Deadline.ui),
|
|
|
|
|
"配对新主机 entry not found on the session list"
|
|
|
|
|
)
|
|
|
|
|
emptyAddHost.tap()
|
|
|
|
|
try fillPairingForm(app, server: server)
|
|
|
|
|
try selectJustPairedHost(app, server: server, hostMenu: hostMenu)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
Thread.sleep(forTimeInterval: 0.5)
|
|
|
|
|
}
|
|
|
|
|
XCTFail("neither PairingScreen nor session list appeared within \(Deadline.launchSurface)s")
|
|
|
|
|
throw TestError.surfaceNotFound
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Types the URL, submits, and confirms (loopback → §5.4 no-warning tier,
|
|
|
|
|
/// so the confirm page has no blocking acknowledgement).
|
|
|
|
|
private func fillPairingForm(_ app: XCUIApplication, server: URL) throws {
|
|
|
|
|
let urlField = app.textFields["pairing.urlField"].firstMatch
|
|
|
|
|
XCTAssertTrue(
|
|
|
|
|
urlField.waitForExistence(timeout: Deadline.ui),
|
|
|
|
|
"pairing URL field not found"
|
|
|
|
|
)
|
|
|
|
|
urlField.tap()
|
|
|
|
|
urlField.typeText(server.absoluteString)
|
|
|
|
|
app.buttons["pairing.submitButton"].firstMatch.tap()
|
|
|
|
|
|
|
|
|
|
let confirmButton = app.buttons["pairing.confirmButton"].firstMatch
|
|
|
|
|
XCTAssertTrue(
|
|
|
|
|
confirmButton.waitForExistence(timeout: Deadline.ui),
|
|
|
|
|
"pairing confirm page (连接) not reached — input rejected? "
|
|
|
|
|
+ "(on-screen: \(visibleStaticTexts(app)))"
|
|
|
|
|
)
|
|
|
|
|
// Guard against typeText mangling: the confirm page must display the
|
|
|
|
|
// EXACT parsed origin (single-point derivation shows what will probe).
|
|
|
|
|
XCTAssertTrue(
|
|
|
|
|
app.staticTexts[expectedOrigin(server)].firstMatch.exists,
|
|
|
|
|
"confirm page does not show \(expectedOrigin(server)) — the typed "
|
|
|
|
|
+ "URL was mangled (on-screen: \(visibleStaticTexts(app)))"
|
|
|
|
|
)
|
|
|
|
|
confirmButton.tap()
|
|
|
|
|
// Probe runs (GET /live-sessions + a WS round trip). Wait for the
|
|
|
|
|
// WHOLE pairing surface to leave — its navigation bar (配对主机)
|
|
|
|
|
// covers the confirm page, the transient 已配对 page AND the sheet's
|
|
|
|
|
// dismissal animation (waiting only for the confirm button raced the
|
|
|
|
|
// sheet and tapped an occluded toolbar — run-5 hit point {-1,-1}).
|
|
|
|
|
// A probe failure shows the FailureView (重试) — fail FAST with its
|
|
|
|
|
// on-screen copy.
|
|
|
|
|
let pairingNavBar = app.navigationBars["配对主机"].firstMatch
|
|
|
|
|
let retryButton = app.buttons["重试"].firstMatch
|
|
|
|
|
let resolveDeadline = Date().addingTimeInterval(Deadline.ui)
|
|
|
|
|
while pairingNavBar.exists {
|
|
|
|
|
if retryButton.exists {
|
|
|
|
|
XCTFail("pairing probe FAILED — on-screen: \(visibleStaticTexts(app))")
|
|
|
|
|
throw TestError.surfaceNotFound
|
|
|
|
|
}
|
|
|
|
|
if Date() >= resolveDeadline {
|
|
|
|
|
XCTFail("pairing did not complete within \(Deadline.ui)s — "
|
|
|
|
|
+ "on-screen: \(visibleStaticTexts(app))")
|
|
|
|
|
throw TestError.surfaceNotFound
|
|
|
|
|
}
|
|
|
|
|
Thread.sleep(forTimeInterval: 0.5)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `scheme://host:port` exactly as HostEndpoint.originHeader derives it.
|
|
|
|
|
private func expectedOrigin(_ server: URL) -> String {
|
|
|
|
|
let scheme = server.scheme ?? "http"
|
|
|
|
|
let host = server.host ?? ""
|
|
|
|
|
let port = server.port.map { ":\($0)" } ?? ""
|
|
|
|
|
return "\(scheme)://\(host)\(port)"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// First dozen visible static texts — failure-message diagnostics.
|
|
|
|
|
private func visibleStaticTexts(_ app: XCUIApplication) -> String {
|
|
|
|
|
app.staticTexts.allElementsBoundByIndex
|
|
|
|
|
.prefix(12)
|
|
|
|
|
.map(\.label)
|
|
|
|
|
.joined(separator: " | ")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Branch B only: the add-host sheet upserts (appends) the new host but
|
|
|
|
|
/// keeps the previously active one. The just-added host is the LAST menu
|
|
|
|
|
/// row bearing the loopback host label; tapping it selects it (or no-ops
|
|
|
|
|
/// if it is already active — both are fine).
|
|
|
|
|
private func selectJustPairedHost(
|
|
|
|
|
_ app: XCUIApplication, server: URL, hostMenu: XCUIElement
|
|
|
|
|
) throws {
|
|
|
|
|
let hostLabel = server.host ?? "127.0.0.1"
|
|
|
|
|
XCTAssertTrue(
|
|
|
|
|
hostMenu.waitForExistence(timeout: Deadline.ui),
|
|
|
|
|
"host menu not visible after add-host sheet dismissed"
|
|
|
|
|
)
|
|
|
|
|
// The sheet's dismissal animation can leave the menu present-but-not-
|
|
|
|
|
// hittable for a beat — tapping then dies with kAXErrorCannotComplete.
|
|
|
|
|
let hittableDeadline = Date().addingTimeInterval(Deadline.ui)
|
|
|
|
|
while !hostMenu.isHittable {
|
|
|
|
|
if Date() >= hittableDeadline {
|
|
|
|
|
XCTFail("host menu never became hittable after the add-host sheet")
|
|
|
|
|
throw TestError.surfaceNotFound
|
|
|
|
|
}
|
|
|
|
|
Thread.sleep(forTimeInterval: 0.5)
|
|
|
|
|
}
|
|
|
|
|
hostMenu.tap()
|
|
|
|
|
let rows = app.buttons.matching(
|
|
|
|
|
NSPredicate(format: "label == %@ AND identifier != %@", hostLabel, "sessions.hostMenu")
|
|
|
|
|
)
|
|
|
|
|
let rowDeadline = Date().addingTimeInterval(Deadline.ui)
|
|
|
|
|
while rows.count == 0, Date() < rowDeadline {
|
|
|
|
|
Thread.sleep(forTimeInterval: 0.5)
|
|
|
|
|
}
|
|
|
|
|
let allRows = rows.allElementsBoundByIndex
|
|
|
|
|
guard let lastRow = allRows.last else {
|
|
|
|
|
XCTFail("no host menu row labelled \(hostLabel) after pairing it")
|
|
|
|
|
throw TestError.surfaceNotFound
|
|
|
|
|
}
|
|
|
|
|
lastRow.tap()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Server oracles (runner-side URLSession)
|
|
|
|
|
|
|
|
|
|
private func liveSessionIds(_ server: URL) throws -> Set<String> {
|
|
|
|
|
let (status, data) = try httpGET(server.appendingPathComponent("live-sessions"))
|
|
|
|
|
guard status == 200, let data,
|
|
|
|
|
let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
|
|
|
|
else {
|
|
|
|
|
throw TestError.httpFailed("GET /live-sessions → \(status.map(String.init) ?? "no response")")
|
|
|
|
|
}
|
|
|
|
|
return Set(list.compactMap { $0["id"] as? String })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Polls /live-sessions until a session NOT in `excluding` reports
|
|
|
|
|
/// clientCount >= 1 (the app's WS attach) — returns its id.
|
|
|
|
|
private func pollForAttachedSession(
|
|
|
|
|
_ server: URL, excluding: Set<String>
|
|
|
|
|
) throws -> String {
|
|
|
|
|
let deadline = Date().addingTimeInterval(Deadline.attach)
|
|
|
|
|
while Date() < deadline {
|
|
|
|
|
let (status, data) = try httpGET(server.appendingPathComponent("live-sessions"))
|
|
|
|
|
if status == 200, let data,
|
|
|
|
|
let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
|
|
|
|
|
for entry in list {
|
|
|
|
|
guard let id = entry["id"] as? String,
|
|
|
|
|
!excluding.contains(id),
|
|
|
|
|
let clientCount = entry["clientCount"] as? Int,
|
|
|
|
|
clientCount >= 1
|
|
|
|
|
else { continue }
|
|
|
|
|
return id
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Thread.sleep(forTimeInterval: Deadline.pollPause)
|
|
|
|
|
}
|
|
|
|
|
XCTFail("no newly attached session appeared in /live-sessions within \(Deadline.attach)s")
|
|
|
|
|
throw TestError.pollTimedOut("attach")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Polls /live-sessions/:id/preview until the ring-buffer tail contains
|
|
|
|
|
/// `marker` (the shell OUTPUT of the typed command).
|
|
|
|
|
private func pollPreview(
|
|
|
|
|
_ server: URL, sessionId: String, contains marker: String
|
|
|
|
|
) throws {
|
|
|
|
|
let url = server
|
|
|
|
|
.appendingPathComponent("live-sessions")
|
|
|
|
|
.appendingPathComponent(sessionId)
|
|
|
|
|
.appendingPathComponent("preview")
|
|
|
|
|
let deadline = Date().addingTimeInterval(Deadline.marker)
|
|
|
|
|
while Date() < deadline {
|
|
|
|
|
let (status, data) = try httpGET(url)
|
|
|
|
|
if status == 200, let data,
|
|
|
|
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
|
|
|
let text = obj["data"] as? String,
|
|
|
|
|
text.contains(marker) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
Thread.sleep(forTimeInterval: Deadline.pollPause)
|
|
|
|
|
}
|
|
|
|
|
XCTFail("marker \(marker) never showed up in /live-sessions/\(sessionId)/preview within \(Deadline.marker)s")
|
|
|
|
|
throw TestError.pollTimedOut("marker")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Held permission (mimics the Claude Code PermissionRequest hook)
|
|
|
|
|
|
|
|
|
|
/// Fire-and-park POST /hook/permission: the server HOLDS the response until
|
|
|
|
|
/// a client decides (or its 5-min default timeout). Returns a handle the
|
|
|
|
|
/// test awaits AFTER tapping ✓ Approve.
|
|
|
|
|
private func startHeldPermissionRequest(
|
|
|
|
|
_ server: URL, sessionId: String, tool: String
|
|
|
|
|
) -> HeldHTTPResponse {
|
|
|
|
|
var request = URLRequest(url: server.appendingPathComponent("hook/permission"))
|
|
|
|
|
request.httpMethod = "POST"
|
|
|
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
|
request.setValue(sessionId, forHTTPHeaderField: "X-Webterm-Session")
|
|
|
|
|
request.timeoutInterval = 120
|
|
|
|
|
request.httpBody = try? JSONSerialization.data(withJSONObject: [
|
|
|
|
|
"hook_event_name": "PermissionRequest",
|
|
|
|
|
"tool_name": tool,
|
|
|
|
|
])
|
|
|
|
|
let held = HeldHTTPResponse()
|
|
|
|
|
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
|
|
|
|
held.complete(
|
|
|
|
|
status: (response as? HTTPURLResponse)?.statusCode,
|
|
|
|
|
data: data,
|
|
|
|
|
errorDescription: error.map(String.init(describing:))
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
task.resume()
|
|
|
|
|
return held
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extracts hookSpecificOutput.decision.behavior from the resolved hold.
|
|
|
|
|
private static func decisionBehavior(in data: Data?) -> String? {
|
|
|
|
|
guard let data,
|
|
|
|
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
|
|
|
let output = obj["hookSpecificOutput"] as? [String: Any],
|
|
|
|
|
let decision = output["decision"] as? [String: Any]
|
|
|
|
|
else { return nil }
|
|
|
|
|
return decision["behavior"] as? String
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Bounded synchronous HTTP (URLSession callbacks run off-thread)
|
|
|
|
|
|
|
|
|
|
private func httpGET(_ url: URL) throws -> (Int?, Data?) {
|
|
|
|
|
var request = URLRequest(url: url)
|
|
|
|
|
request.timeoutInterval = Deadline.http
|
|
|
|
|
let box = HeldHTTPResponse()
|
|
|
|
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
|
|
|
box.complete(
|
|
|
|
|
status: (response as? HTTPURLResponse)?.statusCode,
|
|
|
|
|
data: data,
|
|
|
|
|
errorDescription: error.map(String.init(describing:))
|
|
|
|
|
)
|
|
|
|
|
}.resume()
|
|
|
|
|
guard let result = box.waitForResult(timeout: Deadline.http + 2) else {
|
|
|
|
|
throw TestError.httpFailed("GET \(url) timed out after \(Deadline.http + 2)s")
|
|
|
|
|
}
|
|
|
|
|
if let errorDescription = result.errorDescription {
|
|
|
|
|
throw TestError.httpFailed("GET \(url) failed: \(errorDescription)")
|
|
|
|
|
}
|
|
|
|
|
return (result.status, result.data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Lock-protected completion box for URLSession callbacks (they arrive on the
|
|
|
|
|
/// session's delegate queue; the test thread waits with a bounded semaphore).
|
|
|
|
|
/// `@unchecked Sendable`: every mutable field is guarded by `lock`.
|
|
|
|
|
private final class HeldHTTPResponse: @unchecked Sendable {
|
|
|
|
|
struct Result {
|
|
|
|
|
let status: Int?
|
|
|
|
|
let data: Data?
|
|
|
|
|
let errorDescription: String?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private let lock = NSLock()
|
|
|
|
|
private let semaphore = DispatchSemaphore(value: 0)
|
|
|
|
|
private var result: Result?
|
|
|
|
|
|
|
|
|
|
func complete(status: Int?, data: Data?, errorDescription: String?) {
|
|
|
|
|
lock.lock()
|
|
|
|
|
result = Result(status: status, data: data, errorDescription: errorDescription)
|
|
|
|
|
lock.unlock()
|
|
|
|
|
semaphore.signal()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bounded wait for the response; nil = still pending after `timeout`.
|
|
|
|
|
func waitForResult(timeout: TimeInterval) -> Result? {
|
|
|
|
|
guard semaphore.wait(timeout: .now() + timeout) == .success else { return nil }
|
|
|
|
|
lock.lock()
|
|
|
|
|
defer { lock.unlock() }
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
}
|