Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 47 additions & 9 deletions Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import SweetCookieKit

#if os(macOS)
Expand Down Expand Up @@ -610,15 +613,18 @@ public struct CursorStatusProbe: Sendable {
public let baseURL: URL
public var timeout: TimeInterval = 15.0
private let browserDetection: BrowserDetection
private let urlSession: URLSession

public init(
baseURL: URL = URL(string: "https://cursor.com")!,
timeout: TimeInterval = 15.0,
browserDetection: BrowserDetection)
browserDetection: BrowserDetection,
urlSession: URLSession = .shared)
{
self.baseURL = baseURL
self.timeout = timeout
self.browserDetection = browserDetection
self.urlSession = urlSession
}

/// Fetch Cursor usage with manual cookie header (for debugging).
Expand Down Expand Up @@ -807,11 +813,41 @@ public struct CursorStatusProbe: Sendable {
}

private func fetchWithCookieHeader(_ cookieHeader: String) async throws -> CursorStatusSnapshot {
async let usageSummaryTask = self.fetchUsageSummary(cookieHeader: cookieHeader)
async let userInfoTask = self.fetchUserInfo(cookieHeader: cookieHeader)
enum FetchPart: Sendable {
case usageSummary((CursorUsageSummary, String))
case userInfo(Result<CursorUserInfo, Error>)
}

var usageSummaryResult: (CursorUsageSummary, String)?
var userInfo: CursorUserInfo?

let (usageSummary, rawJSON) = try await usageSummaryTask
let userInfo = try? await userInfoTask
try await withThrowingTaskGroup(of: FetchPart.self) { group in
group.addTask {
try await .usageSummary(self.fetchUsageSummary(cookieHeader: cookieHeader))
}
group.addTask {
do {
return try await .userInfo(.success(self.fetchUserInfo(cookieHeader: cookieHeader)))
} catch {
return .userInfo(.failure(error))
}
}

while let result = try await group.next() {
switch result {
case let .usageSummary(value):
usageSummaryResult = value
case let .userInfo(value):
userInfo = try? value.get()
}
}
}

guard let usageSummaryResult else {
throw CursorStatusProbeError.networkError("Cursor usage summary fetch did not complete")
}

let (usageSummary, rawJSON) = usageSummaryResult

// Fetch legacy request usage only if user has a sub ID.
// Uses try? to avoid breaking the flow for users where this endpoint fails or returns unexpected data.
Expand Down Expand Up @@ -847,7 +883,7 @@ public struct CursorStatusProbe: Sendable {
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")

let (data, response) = try await URLSession.shared.data(for: request)
let (data, response) = try await self.urlSession.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw CursorStatusProbeError.networkError("Invalid response")
Expand Down Expand Up @@ -880,7 +916,7 @@ public struct CursorStatusProbe: Sendable {
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")

let (data, response) = try await URLSession.shared.data(for: request)
let (data, response) = try await self.urlSession.data(for: request)

guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw CursorStatusProbeError.networkError("Failed to fetch user info")
Expand All @@ -901,7 +937,7 @@ public struct CursorStatusProbe: Sendable {
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")

let (data, response) = try await URLSession.shared.data(for: request)
let (data, response) = try await self.urlSession.data(for: request)

guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw CursorStatusProbeError.networkError("Failed to fetch request usage")
Expand Down Expand Up @@ -1025,11 +1061,13 @@ public struct CursorStatusProbe: Sendable {
public init(
baseURL: URL = URL(string: "https://cursor.com")!,
timeout: TimeInterval = 15.0,
browserDetection: BrowserDetection)
browserDetection: BrowserDetection,
urlSession: URLSession = .shared)
{
_ = baseURL
_ = timeout
_ = browserDetection
_ = urlSession
}

public func fetch(logger: ((String) -> Void)? = nil) async throws -> CursorStatusSnapshot {
Expand Down
185 changes: 185 additions & 0 deletions Tests/CodexBarTests/CursorStatusProbeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import CodexBarCore

@Suite(.serialized)
struct CursorStatusProbeTests {
// MARK: - Usage Summary Parsing

Expand Down Expand Up @@ -872,3 +873,187 @@ struct CursorStatusProbeTests {
return CursorCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: sourceLabel)
}
}

private func makeCursorStatusProbeSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [CursorStatusProbeStubURLProtocol.self]
return URLSession(configuration: config)
}

private func makeCursorStatusProbeResponse(
url: URL,
body: String,
statusCode: Int,
contentType: String = "application/json") -> (HTTPURLResponse, Data)
{
let response = HTTPURLResponse(
url: url,
statusCode: statusCode,
httpVersion: nil,
headerFields: ["Content-Type": contentType])!
return (response, Data(body.utf8))
}

extension CursorStatusProbeTests {
@Test
func `fetch ignores user info failure when usage summary succeeds`() async throws {
defer {
CursorStatusProbeStubURLProtocol.reset()
}
CursorStatusProbeStubURLProtocol.reset()

CursorStatusProbeStubURLProtocol.setHandler { request in
let requestURL = try #require(request.url)

switch requestURL.path {
case "/api/usage-summary":
return makeCursorStatusProbeResponse(
url: requestURL,
body: """
{
"membershipType": "pro",
"individualUsage": {
"plan": {
"used": 1500,
"limit": 5000,
"totalPercentUsed": 30.0
}
}
}
""",
statusCode: 200)
case "/api/auth/me":
return makeCursorStatusProbeResponse(
url: requestURL,
body: #"{"error":"nope"}"#,
statusCode: 500)
default:
throw URLError(.badURL)
}
}

let baseURL = try #require(URL(string: "https://cursor.test"))
let snapshot = try await CursorStatusProbe(
baseURL: baseURL,
browserDetection: BrowserDetection(cacheTTL: 0),
urlSession: makeCursorStatusProbeSession()).fetchWithManualCookies("auth=test")

#expect(snapshot.planPercentUsed == 30.0)
#expect(snapshot.accountEmail == nil)
#expect(CursorStatusProbeStubURLProtocol.requestCount == 2)
}

@Test
func `fetch fails cleanly when usage summary fails`() async {
defer {
CursorStatusProbeStubURLProtocol.reset()
}
CursorStatusProbeStubURLProtocol.reset()

CursorStatusProbeStubURLProtocol.setHandler { request in
let requestURL = try #require(request.url)

switch requestURL.path {
case "/api/usage-summary":
return makeCursorStatusProbeResponse(
url: requestURL,
body: #"{"error":"denied"}"#,
statusCode: 500)
case "/api/auth/me":
return makeCursorStatusProbeResponse(
url: requestURL,
body: """
{
"email": "user@example.com",
"email_verified": true,
"name": "Test User",
"sub": "auth0|12345"
}
""",
statusCode: 200)
default:
throw URLError(.badURL)
}
}

do {
let baseURL = try #require(URL(string: "https://cursor.test"))
_ = try await CursorStatusProbe(
baseURL: baseURL,
browserDetection: BrowserDetection(cacheTTL: 0),
urlSession: makeCursorStatusProbeSession()).fetchWithManualCookies("auth=test")
Issue.record("Expected usage summary failure to be surfaced")
} catch let error as CursorStatusProbeError {
guard case let .networkError(message) = error else {
Issue.record("Expected networkError, got: \(error)")
return
}
#expect(message == "HTTP 500")
#expect(CursorStatusProbeStubURLProtocol.requestPaths.contains("/api/usage-summary"))
} catch {
Issue.record("Expected CursorStatusProbeError, got: \(error)")
}
}
}

final class CursorStatusProbeStubURLProtocol: URLProtocol {
private struct State {
var requests: [URLRequest] = []
var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
}

private static let lock = NSLock()
private nonisolated(unsafe) static var state = State()

static func setHandler(_ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)) {
self.lock.lock()
self.state.handler = handler
self.lock.unlock()
}

static func reset() {
self.lock.lock()
self.state = State()
self.lock.unlock()
}

static var requestCount: Int {
lock.lock()
defer { Self.lock.unlock() }
return state.requests.count
}

static var requestPaths: [String] {
lock.lock()
defer { Self.lock.unlock() }
return state.requests.compactMap { $0.url?.path }
}

override static func canInit(with request: URLRequest) -> Bool {
true
}

override static func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}

override func startLoading() {
let handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?
Self.lock.lock()
Self.state.requests.append(self.request)
handler = Self.state.handler
Self.lock.unlock()

do {
let handler = try #require(handler)
let (response, data) = try handler(self.request)
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
self.client?.urlProtocol(self, didLoad: data)
self.client?.urlProtocolDidFinishLoading(self)
} catch {
self.client?.urlProtocol(self, didFailWithError: error)
}
}

override func stopLoading() {}
}