diff --git a/Flights/Services/AirlineLoadService.swift b/Flights/Services/AirlineLoadService.swift new file mode 100644 index 0000000..876c728 --- /dev/null +++ b/Flights/Services/AirlineLoadService.swift @@ -0,0 +1,700 @@ +import Foundation + +/// Queries airline APIs for flight load and standby data. +/// Each airline has its own endpoint and response format. +actor AirlineLoadService { + + // MARK: - Properties + + private let session: URLSession + private var unitedToken: (hash: String, expiresAt: Date)? + + // MARK: - Init + + init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + session = URLSession(configuration: config) + } + + // MARK: - Public Router + + /// Route to the correct airline based on IATA code. + func fetchLoad( + airlineCode: String, + flightNumber: String, + date: Date, + origin: String, + destination: String + ) async -> FlightLoad? { + let code = airlineCode.uppercased() + print("[LoadService] Fetching load for \(code) flight \(flightNumber) \(origin)->\(destination)") + switch code { + case "UA": return await fetchUnitedLoad(flightNumber: flightNumber, date: date, origin: origin) + case "AA": return await fetchAmericanLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination) + case "NK": return await fetchSpiritStatus(origin: origin, destination: destination, date: date) + case "KE": return await fetchKoreanAirLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination) + case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date) + case "AS": return await fetchAlaskaStatus(flightNumber: flightNumber, date: date) + case "EK": return await fetchEmiratesStatus(flightNumber: flightNumber, date: date) + case "XE": return await fetchJSXLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination) + default: + print("[LoadService] Unsupported airline: \(code)") + return nil + } + } + + // MARK: - United Airlines + + /// Fetch an anonymous auth token from United, caching until expiry. + private func getUnitedToken() async -> String? { + if let cached = unitedToken, cached.expiresAt > Date() { + return cached.hash + } + + guard let url = URL(string: "https://www.united.com/api/auth/anonymous-token") else { return nil } + + do { + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataObj = json["data"] as? [String: Any], + let tokenObj = dataObj["token"] as? [String: Any], + let hash = tokenObj["hash"] as? String else { + return nil + } + + // Cache for 25 minutes (tokens typically last 30). + unitedToken = (hash: hash, expiresAt: Date().addingTimeInterval(25 * 60)) + return hash + } catch { + return nil + } + } + + private func fetchUnitedLoad(flightNumber: String, date: Date, origin: String) async -> FlightLoad? { + guard let token = await getUnitedToken() else { return nil } + + let num = stripAirlinePrefix(flightNumber) + let dateStr = Self.dashDateFormatter.string(from: date) + + guard let url = URL(string: "https://www.united.com/api/flightstatus/upgradeListExtended?flightNumber=\(num)&flightDate=\(dateStr)&fromAirportCode=\(origin.uppercased())") else { + return nil + } + + do { + var request = URLRequest(url: url) + request.setValue("bearer \(token)", forHTTPHeaderField: "x-authorization-api") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + print("[UA] HTTP status: \((response as? HTTPURLResponse)?.statusCode ?? -1)") + return nil + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + print("[UA] Failed to parse JSON") + return nil + } + + // === LOGGING: Dump top-level keys and structure === + print("[UA] ===== RESPONSE FOR UA\(num) =====") + print("[UA] Top-level keys: \(json.keys.sorted())") + + if let segment = json["segment"] as? [String: Any] { + print("[UA] segment: \(segment)") + } + + if let numCabins = json["numberOfCabins"] { + print("[UA] numberOfCabins: \(numCabins)") + } + + // Log pbts + if let pbts = json["pbts"] as? [[String: Any]] { + print("[UA] pbts count: \(pbts.count)") + for (i, entry) in pbts.enumerated() { + print("[UA] pbts[\(i)]: \(entry)") + } + } else { + print("[UA] pbts: MISSING or wrong type") + } + + // Log ALL cabin-level keys (front, middle, rear, etc.) + for key in json.keys.sorted() { + if let cabinData = json[key] as? [String: Any], + cabinData.keys.contains("standby") || cabinData.keys.contains("cleared") { + let clearedList = cabinData["cleared"] as? [[String: Any]] ?? [] + let standbyListRaw = cabinData["standby"] as? [[String: Any]] ?? [] + print("[UA] '\(key)' cabin: cleared=\(clearedList.count), standby=\(standbyListRaw.count)") + for pax in clearedList.prefix(3) { + print("[UA] cleared: \(pax["passengerName"] ?? "?") seat=\(pax["seatNumber"] ?? "?")") + } + for pax in standbyListRaw.prefix(3) { + print("[UA] standby: \(pax["passengerName"] ?? "?") seat=\(pax["seatNumber"] ?? "?")") + } + if clearedList.count > 3 { print("[UA] ... +\(clearedList.count - 3) more cleared") } + if standbyListRaw.count > 3 { print("[UA] ... +\(standbyListRaw.count - 3) more standby") } + } + } + + // Log checkInSummaries if present + if let checkIn = json["checkInSummaries"] as? [[String: Any]] { + print("[UA] checkInSummaries count: \(checkIn.count)") + for entry in checkIn { + print("[UA] checkIn: \(entry)") + } + } + + print("[UA] ===== END RESPONSE =====") + + // Parse cabin loads from pbts array + var cabins: [CabinLoad] = [] + if let pbts = json["pbts"] as? [[String: Any]] { + for entry in pbts { + let cabin = entry["cabin"] as? String ?? "Unknown" + let capacity = entry["capacity"] as? Int ?? 0 + let booked = entry["booked"] as? Int ?? 0 + let revStandby = entry["revenueStandby"] as? Int ?? 0 + let sa = entry["sa"] as? Int ?? 0 + let waitList = entry["waitList"] as? Int ?? 0 + let jump = entry["jump"] as? Int ?? 0 + let ps = entry["ps"] as? Int ?? 0 + + print("[UA] Cabin '\(cabin)': cap=\(capacity) booked=\(booked) sa=\(sa) revSB=\(revStandby) waitList=\(waitList) jump=\(jump) ps=\(ps)") + + cabins.append(CabinLoad( + name: cabin, + capacity: capacity, + booked: booked, + revenueStandby: revStandby, + nonRevStandby: sa, + waitListCount: waitList, + jumpSeat: jump + )) + } + } + + // Parse standby / upgrade lists from ALL cabin sections + var standbyList: [StandbyPassenger] = [] + var upgradeList: [StandbyPassenger] = [] + + // Check all keys that have cleared/standby sub-arrays + for key in json.keys.sorted() { + guard let cabinData = json[key] as? [String: Any], + cabinData.keys.contains("standby") || cabinData.keys.contains("cleared") else { + continue + } + + let cabinName = key.capitalized + + if let cleared = cabinData["cleared"] as? [[String: Any]] { + for pax in cleared { + upgradeList.append(StandbyPassenger( + order: upgradeList.count + 1, + displayName: pax["passengerName"] as? String ?? "", + cleared: true, + seat: pax["seatNumber"] as? String, + listName: cabinName + )) + } + } + + if let waiting = cabinData["standby"] as? [[String: Any]] { + for pax in waiting { + let seat = pax["seatNumber"] as? String + let hasSeat = seat != nil && !seat!.isEmpty + + if hasSeat { + // Has a seat = already cleared from upgrade waitlist + upgradeList.append(StandbyPassenger( + order: upgradeList.count + 1, + displayName: pax["passengerName"] as? String ?? "", + cleared: true, + seat: seat, + listName: cabinName + )) + } else { + // No seat = actually waiting on standby + standbyList.append(StandbyPassenger( + order: standbyList.count + 1, + displayName: pax["passengerName"] as? String ?? "", + cleared: false, + seat: nil, + listName: cabinName + )) + } + } + } + } + + return FlightLoad( + airlineCode: "UA", + flightNumber: "UA\(num)", + cabins: cabins, + standbyList: standbyList, + upgradeList: upgradeList, + seatAvailability: [] + ) + } catch { + return nil + } + } + + // MARK: - American Airlines + + private func fetchAmericanLoad( + flightNumber: String, + date: Date, + origin: String, + destination: String + ) async -> FlightLoad? { + let num = stripAirlinePrefix(flightNumber) + let dateStr = Self.dashDateFormatter.string(from: date) + + var components = URLComponents(string: "https://cdn.flyaa.aa.com/api/mobile/loyalty/waitlist/v1.2") + components?.queryItems = [ + URLQueryItem(name: "carrierCode", value: "AA"), + URLQueryItem(name: "flightNumber", value: num), + URLQueryItem(name: "departureDate", value: dateStr), + URLQueryItem(name: "originAirportCode", value: origin.uppercased()), + URLQueryItem(name: "destinationAirportCode", value: destination.uppercased()) + ] + + guard let url = components?.url else { return nil } + + do { + var request = URLRequest(url: url) + request.setValue("Android/2025.31 Pixel 7|14|1080|2400|1.0|AmericanAirlines", forHTTPHeaderField: "User-Agent") + request.setValue("MOBILE", forHTTPHeaderField: "x-clientid") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(UUID().uuidString, forHTTPHeaderField: "Device-ID") + request.setValue("fs", forHTTPHeaderField: "x-referrer") + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let waitListArray = json["waitList"] as? [[String: Any]] else { + return nil + } + + var seatAvailability: [SeatAvailability] = [] + var standbyList: [StandbyPassenger] = [] + var upgradeList: [StandbyPassenger] = [] + + for entry in waitListArray { + let listName = entry["listName"] as? String ?? "Unknown" + let seatsAvailable = entry["seatsAvailableValue"] as? Int ?? 0 + let semanticColor = entry["seatsAvailableSemanticColor"] as? String ?? "success" + + let label = listName == "First" ? "First Class Upgrades" : "\(listName) Seats" + + seatAvailability.append(SeatAvailability( + label: label, + available: seatsAvailable, + color: SeatAvailabilityColor(rawValue: semanticColor) ?? .success + )) + + // Parse passengers + if let passengers = entry["passengers"] as? [[String: Any]] { + for pax in passengers { + let order = pax["order"] as? Int ?? 0 + let displayName = pax["displayName"] as? String ?? "" + let cleared = pax["cleared"] as? Bool ?? false + let seat = pax["seat"] as? String + + let passenger = StandbyPassenger( + order: order, + displayName: displayName, + cleared: cleared, + seat: seat, + listName: listName + ) + + if listName.lowercased() == "standby" { + standbyList.append(passenger) + } else { + upgradeList.append(passenger) + } + } + } + } + + return FlightLoad( + airlineCode: "AA", + flightNumber: "AA\(num)", + cabins: [], + standbyList: standbyList, + upgradeList: upgradeList, + seatAvailability: seatAvailability + ) + } catch { + return nil + } + } + + // MARK: - Spirit Airlines + + private func fetchSpiritStatus(origin: String, destination: String, date: Date) async -> FlightLoad? { + guard let url = URL(string: "https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI") else { + print("[NK] Invalid URL") + return nil + } + + let dateStr = Self.dashDateFormatter.string(from: date) + let body: [String: String] = [ + "departureStation": origin.uppercased(), + "arrivalStation": destination.uppercased(), + "departureDate": dateStr + ] + + print("[NK] POST \(url) body: \(body)") + + do { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("c6567af50d544dfbb3bc5dd99c6bb177", forHTTPHeaderField: "Ocp-Apim-Subscription-Key") + request.setValue("Android", forHTTPHeaderField: "Platform") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: request) + let http = response as? HTTPURLResponse + print("[NK] HTTP status: \(http?.statusCode ?? -1)") + + if let bodyStr = String(data: data, encoding: .utf8) { + print("[NK] Response body: \(bodyStr.prefix(500))") + } + + guard http?.statusCode == 200 else { + print("[NK] Non-200 response") + return nil + } + + // Spirit is a ULCC with no standby program. + return FlightLoad( + airlineCode: "NK", + flightNumber: "NK", + cabins: [], + standbyList: [], + upgradeList: [], + seatAvailability: [] + ) + } catch { + print("[NK] Error: \(error)") + return nil + } + } + + // MARK: - Korean Air + + private func fetchKoreanAirLoad( + flightNumber: String, + date: Date, + origin: String, + destination: String + ) async -> FlightLoad? { + guard let url = URL(string: "https://www.koreanair.com/api/et/ibeSupport/flightSeatCount") else { + return nil + } + + let num = stripAirlinePrefix(flightNumber) + let dateStr = Self.compactDateFormatter.string(from: date) + + let body: [String: String] = [ + "carrierCode": "KE", + "flightNumber": num, + "departureAirport": origin.uppercased(), + "arrivalAirport": destination.uppercased(), + "departureDate": dateStr + ] + + do { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("pc", forHTTPHeaderField: "channel") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + + let seatCount = json["seatCount"] as? Int ?? 0 + + let cabin = CabinLoad( + name: "Economy", + capacity: 0, + booked: 0, + revenueStandby: 0, + nonRevStandby: seatCount + ) + + return FlightLoad( + airlineCode: "KE", + flightNumber: "KE\(num)", + cabins: [cabin], + standbyList: [], + upgradeList: [], + seatAvailability: [] + ) + } catch { + return nil + } + } + + // MARK: - JetBlue + + private func fetchJetBlueStatus(flightNumber: String, date: Date) async -> FlightLoad? { + let num = stripAirlinePrefix(flightNumber) + let dateStr = Self.dashDateFormatter.string(from: date) + + guard let url = URL(string: "https://az-api.jetblue.com/flight-status/get-by-number?number=\(num)&date=\(dateStr)") else { + print("[B6] Invalid URL") + return nil + } + + print("[B6] GET \(url)") + + do { + var request = URLRequest(url: url) + request.setValue("49fc015f1ba44abf892d2b8961612378", forHTTPHeaderField: "apikey") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + let http = response as? HTTPURLResponse + print("[B6] HTTP status: \(http?.statusCode ?? -1)") + + if let bodyStr = String(data: data, encoding: .utf8) { + print("[B6] Response: \(bodyStr.prefix(800))") + } + + guard http?.statusCode == 200 else { return nil } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let flights = json["flights"] as? [[String: Any]], + let flight = flights.first, + let legs = flight["legs"] as? [[String: Any]], + let leg = legs.first else { + print("[B6] Failed to parse flight data") + return nil + } + + let flightNo = leg["flightNo"] as? String ?? num + let status = leg["flightStatus"] as? String ?? "Unknown" + print("[B6] Flight B6\(flightNo) status: \(status)") + + // JetBlue flight status only — no seat/standby data without check-in session + return FlightLoad( + airlineCode: "B6", + flightNumber: "B6\(flightNo)", + cabins: [], + standbyList: [], + upgradeList: [], + seatAvailability: [] + ) + } catch { + print("[B6] Error: \(error)") + return nil + } + } + + // MARK: - Alaska Airlines + + private func fetchAlaskaStatus(flightNumber: String, date: Date) async -> FlightLoad? { + let num = stripAirlinePrefix(flightNumber) + let dateStr = Self.dashDateFormatter.string(from: date) + + guard let url = URL(string: "https://www.alaskaair.com/1/guestservices/customermobile/flights/status/AS/\(num)/\(dateStr)") else { + print("[AS] Invalid URL") + return nil + } + + print("[AS] GET \(url)") + + do { + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + let http = response as? HTTPURLResponse + print("[AS] HTTP status: \(http?.statusCode ?? -1)") + + if let bodyStr = String(data: data, encoding: .utf8) { + print("[AS] Response: \(bodyStr.prefix(800))") + } + + guard http?.statusCode == 200 else { return nil } + + // Alaska flight status only — seat data requires confirmation code + return FlightLoad( + airlineCode: "AS", + flightNumber: "AS\(num)", + cabins: [], + standbyList: [], + upgradeList: [], + seatAvailability: [] + ) + } catch { + print("[AS] Error: \(error)") + return nil + } + } + + // MARK: - Emirates + + private func fetchEmiratesStatus(flightNumber: String, date: Date) async -> FlightLoad? { + let num = stripAirlinePrefix(flightNumber) + let dateStr = Self.dashDateFormatter.string(from: date) + + // Pad flight number to 4 digits (Emirates uses 0-padded numbers like "0221") + let paddedNum = String(repeating: "0", count: max(0, 4 - num.count)) + num + + guard let url = URL(string: "https://www.emirates.com/service/flight-status?departureDate=\(dateStr)&flight=\(paddedNum)") else { + print("[EK] Invalid URL") + return nil + } + + print("[EK] GET \(url)") + + do { + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + let http = response as? HTTPURLResponse + print("[EK] HTTP status: \(http?.statusCode ?? -1)") + + if let bodyStr = String(data: data, encoding: .utf8) { + print("[EK] Response: \(bodyStr.prefix(800))") + } + + guard http?.statusCode == 200 else { return nil } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["results"] as? [[String: Any]], + let flight = results.first else { + print("[EK] Failed to parse flight data") + return nil + } + + let flightNo = flight["flightNumber"] as? String ?? num + print("[EK] Flight EK\(flightNo) found") + + // Emirates flight status only — seat/load data requires PNR + return FlightLoad( + airlineCode: "EK", + flightNumber: "EK\(flightNo)", + cabins: [], + standbyList: [], + upgradeList: [], + seatAvailability: [] + ) + } catch { + print("[EK] Error: \(error)") + return nil + } + } + + // MARK: - JSX (JetSuiteX) + + private func fetchJSXLoad(flightNumber: String, date: Date, origin: String, destination: String) async -> FlightLoad? { + let dateStr = Self.dashDateFormatter.string(from: date) + let num = stripAirlinePrefix(flightNumber) + let upperOrigin = origin.uppercased() + let upperDestination = destination.uppercased() + + print("[XE] Fetching JSX for XE\(num) \(upperOrigin)->\(upperDestination) on \(dateStr)") + + let fetcher = await JSXWebViewFetcher() + let result = await fetcher.fetchAvailability( + origin: upperOrigin, + destination: upperDestination, + date: dateStr + ) + + if let error = result.error { + print("[XE] JSX flow failed: \(error)") + return nil + } + + print("[XE] JSX returned \(result.flights.count) unique flights for \(upperOrigin)|\(upperDestination):") + for f in result.flights { + let low = f.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a" + print("[XE] - \(f.flightNumber) \(f.departureLocal) seats=\(f.totalAvailable) low=\(low)") + } + + // Find the specific flight the caller asked for. We compare the + // digits-only portion so "XE280" matches "280", "XE 280", etc. + let targetDigits = num.filter(\.isNumber) + guard let flight = result.flights.first(where: { + $0.flightNumber.filter(\.isNumber) == targetDigits + }) else { + print("[XE] Flight XE\(num) not present in \(result.flights.count) results") + return nil + } + + let capacity = 30 // JSX ERJ-145 approximate capacity + let booked = max(0, capacity - flight.totalAvailable) + + return FlightLoad( + airlineCode: "XE", + flightNumber: flight.flightNumber, + cabins: [ + CabinLoad( + name: "Cabin", + capacity: capacity, + booked: booked, + revenueStandby: 0, + nonRevStandby: 0 + ) + ], + standbyList: [], + upgradeList: [], + seatAvailability: [] + ) + } + + // MARK: - Helpers + + /// Strip any airline prefix from a flight number string. + /// "AA 2238" -> "2238", "UA2238" -> "2238", "2238" -> "2238" + private func stripAirlinePrefix(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + + // Drop leading letters and any space to get just the numeric portion + var start = trimmed.startIndex + while start < trimmed.endIndex && (trimmed[start].isLetter || trimmed[start] == " ") { + start = trimmed.index(after: start) + } + + let result = String(trimmed[start...]) + return result.isEmpty ? trimmed : result + } + + /// "yyyy-MM-dd" formatter for United, American, Spirit + private static let dashDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + return f + }() + + /// "yyyyMMdd" formatter for Korean Air + private static let compactDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyyMMdd" + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + return f + }() +} diff --git a/Flights/Services/JSXWebViewFetcher.swift b/Flights/Services/JSXWebViewFetcher.swift new file mode 100644 index 0000000..5ded5db --- /dev/null +++ b/Flights/Services/JSXWebViewFetcher.swift @@ -0,0 +1,1744 @@ +import Foundation +import WebKit + +// MARK: - Public types + +/// One fare class (e.g. Q/HO "Hop-On", Q/AI "All-In") inside a JSX flight. +struct JSXFareClass: Sendable { + let classOfService: String // "Q", "N", "S", "B" + let productClass: String // "HO" (Hop-On), "AI" (All-In) + let availableCount: Int // seats sellable at this class + let fareTotal: Double // price incl. taxes + let revenueTotal: Double // base fare + let fareBasisCode: String? // "Q00AHOXA" +} + +/// One unique flight surfaced by the JSX /availability/search/simple response. +/// The response typically contains several flights for a given route/date; each +/// entry here represents one of them. +struct JSXFlight: Sendable { + let flightNumber: String // "XE280" + let carrierCode: String // "XE" + let origin: String // "DAL" + let destination: String // "HOU" + let departureLocal: String // "2026-04-15T10:50:00" + let arrivalLocal: String // "2026-04-15T12:05:00" + let stops: Int + let equipmentType: String? // "ER4" + let totalAvailable: Int // sum of classes[].availableCount + let lowestFareTotal: Double? // min of classes[].fareTotal + let classes: [JSXFareClass] // per-class breakdown +} + +/// Result of one run of the JSX WKWebView flow. +struct JSXSearchResult: Sendable { + let flights: [JSXFlight] // one entry per unique flight, empty on failure + let rawSearchBody: String? // raw search/simple JSON, preserved for debug + let error: String? // non-nil iff a step failed +} + +// MARK: - Fetcher + +/// Drives the jsx.com SPA inside a WKWebView to capture the +/// /api/nsk/v4/availability/search/simple response (the one JSX's own website +/// uses to render fares and loads). The flow is broken into explicit steps, +/// each with an action AND a post-condition verification, so failures are +/// pinpointable from the console log. +@MainActor +final class JSXWebViewFetcher: NSObject { + func fetchAvailability( + origin: String, + destination: String, + date: String + ) async -> JSXSearchResult { + let flow = JSXFlow(origin: origin, destination: destination, date: date) + return await flow.run() + } +} + +// MARK: - Flow orchestrator + +@MainActor +private final class JSXFlow { + let origin: String + let destination: String + let date: String + + private var webView: WKWebView! + private var stepNumber = 0 + private var capturedBody: String? + + // Parsed date parts (used by step 14 aria-label match). + private let targetYear: Int + private let targetMonth: Int // 1-12 + private let targetDay: Int + private let targetMonthName: String + + init(origin: String, destination: String, date: String) { + self.origin = origin.uppercased() + self.destination = destination.uppercased() + self.date = date + + let parts = date.split(separator: "-") + let year = parts.count == 3 ? Int(parts[0]) ?? 0 : 0 + let month = parts.count == 3 ? Int(parts[1]) ?? 0 : 0 + let day = parts.count == 3 ? Int(parts[2]) ?? 0 : 0 + let months = [ + "January","February","March","April","May","June", + "July","August","September","October","November","December" + ] + self.targetYear = year + self.targetMonth = month + self.targetDay = day + self.targetMonthName = (month >= 1 && month <= 12) ? months[month - 1] : "" + } + + // MARK: - Run + + func run() async -> JSXSearchResult { + logHeader("JSX FLOW START: \(origin) → \(destination) on \(date)") + + guard targetYear > 0, targetMonth >= 1, targetMonth <= 12, targetDay >= 1 else { + return fail("Invalid date '\(date)' (expected YYYY-MM-DD)") + } + + // ---- STEP 01 ---- + let s01 = await nativeStep("Create WKWebView") { [weak self] in + guard let self else { return (false, "self gone", [:]) } + let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1280, height: 900)) + wv.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15" + self.webView = wv + return (true, "created 1280x900 with Safari UA", [:]) + } verify: { [weak self] in + guard let self else { + return [Verification(name: "webView non-nil", passed: false, detail: "flow deallocated")] + } + return [Verification(name: "webView non-nil", passed: self.webView != nil, detail: "")] + } + if !s01.ok { return fail("Step 01 failed: \(s01.message)") } + + // ---- STEP 02 ---- + let s02 = await nativeStep("Navigate to https://www.jsx.com/") { [weak self] in + guard let self else { return (false, "self gone", [:]) } + guard let url = URL(string: "https://www.jsx.com/") else { + return (false, "invalid URL", [:]) + } + let loaded = await self.navigateAndWait(url) + return (loaded, loaded ? "didFinishNavigation" : "navigation failed", [:]) + } verify: { [weak self] in + guard let self else { + return [Verification(name: "location.href contains jsx.com", passed: false, detail: "flow deallocated")] + } + let v = await self.asyncVerify(name: "location.href contains jsx.com", js: """ + return await (async () => { + const href = location.href || ""; + return JSON.stringify({ ok: href.includes("jsx.com"), detail: href }); + })(); + """) + return [v] + } + if !s02.ok { return fail("Step 02 failed: \(s02.message)") } + + // ---- STEP 03 ---- + let s03 = await jsStep( + "Wait for SPA bootstrap (poll for station buttons)", + action: """ + return await (async () => { + const deadline = Date.now() + 15000; + let count = 0; + while (Date.now() < deadline) { + count = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length; + if (count >= 2) break; + await new Promise(r => setTimeout(r, 250)); + } + return JSON.stringify({ + ok: count >= 2, + message: count >= 2 ? ("station buttons visible: " + count) : ("timeout; count=" + count), + stationButtonCount: count + }); + })(); + """, + verifiers: [ + Verifier(name: "≥2 station buttons visible", js: """ + return await (async () => { + const n = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length; + return JSON.stringify({ ok: n >= 2, detail: "count=" + n }); + })(); + """) + ] + ) + if !s03.ok { return fail("Step 03 failed: \(s03.message)") } + + // ---- STEP 04 ---- + // The interceptor tracks THREE things for every api.jsx.com request: + // 1. `initiatedCalls` — every URL passed into fetch()/XHR.send(), + // even ones that never receive a response (e.g. network failure, + // Akamai HTTP/2 block). + // 2. `allCalls` — every call that completed with any status, plus + // any error text if the request threw. + // 3. `searchSimple`/`lowFare`/`token`/`setCulture` — the specific + // response bodies we care about. + // This lets us distinguish "Angular's search() never fired a request" + // from "Angular fired a request but the network rejected it". + let s04 = await jsStep( + "Install network interceptor", + action: """ + return await (async () => { + if (window.__jsxProbe) { + return JSON.stringify({ ok: true, message: "already installed", already: true }); + } + window.__jsxProbe = { + searchSimple: null, + lowFare: null, + token: null, + setCulture: null, + allCalls: [], + initiatedCalls: [] + }; + + const noteInitiated = (url, method, transport) => { + try { + if (!url.includes("api.jsx.com")) return; + window.__jsxProbe.initiatedCalls.push({ + url, method, transport, t: Date.now() + }); + } catch (_) {} + }; + + const captureIfMatch = (url, method, status, body, transport, error) => { + try { + if (!url.includes("api.jsx.com")) return; + const entry = { url, method, status, body, transport, error: error || null }; + window.__jsxProbe.allCalls.push({ + url, method, status, transport, error: error || null + }); + if (url.includes("/availability/search/simple")) window.__jsxProbe.searchSimple = entry; + else if (url.includes("/lowfare/estimate")) window.__jsxProbe.lowFare = entry; + else if (url.includes("/nsk/v2/token")) window.__jsxProbe.token = entry; + else if (url.includes("/graph/setCulture")) window.__jsxProbe.setCulture = entry; + } catch (_) {} + }; + + const origFetch = window.fetch.bind(window); + window.fetch = async function(input, init) { + const url = typeof input === "string" ? input : (input && input.url ? input.url : ""); + const method = (init && init.method) || "GET"; + noteInitiated(url, method, "fetch"); + let resp; + try { + resp = await origFetch(input, init); + } catch (err) { + captureIfMatch(url, method, 0, "", "fetch", String(err && err.message ? err.message : err)); + throw err; + } + try { + if (url.includes("api.jsx.com")) { + const body = await resp.clone().text(); + captureIfMatch(url, method, resp.status, body, "fetch"); + } + } catch (_) {} + return resp; + }; + + const origOpen = XMLHttpRequest.prototype.open; + const origSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__jsxMethod = method; + this.__jsxUrl = typeof url === "string" ? url : String(url || ""); + return origOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + noteInitiated(this.__jsxUrl || "", this.__jsxMethod || "GET", "xhr"); + this.addEventListener("loadend", () => { + try { + captureIfMatch( + this.__jsxUrl || "", + this.__jsxMethod || "GET", + this.status, + this.responseText || "", + "xhr", + this.status === 0 ? "xhr status 0" : null + ); + } catch (_) {} + }); + this.addEventListener("error", () => { + try { + captureIfMatch( + this.__jsxUrl || "", + this.__jsxMethod || "GET", + 0, "", "xhr", "xhr error event" + ); + } catch (_) {} + }); + return origSend.apply(this, arguments); + }; + + return JSON.stringify({ ok: true, message: "installed fetch+xhr hooks with initiation tracking" }); + })(); + """, + verifiers: [ + Verifier(name: "window.__jsxProbe installed", js: """ + return await (async () => { + const p = window.__jsxProbe; + const ok = typeof p === "object" && p !== null && Array.isArray(p.initiatedCalls); + return JSON.stringify({ ok, detail: ok ? "present" : "missing" }); + })(); + """) + ] + ) + if !s04.ok { return fail("Step 04 failed: \(s04.message)") } + + // ---- STEP 05 ---- + let s05 = await jsStep( + "Dismiss Osano cookie banner", + action: """ + return await (async () => { + const visible = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + const txt = el => (el.innerText || el.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase(); + const actions = []; + + // Class-based accept (most reliable on Osano). + const classAccept = document.querySelector( + ".osano-cm-accept-all, .osano-cm-accept, button.osano-cm-button[class*='accept']" + ); + if (classAccept && visible(classAccept)) { + classAccept.click(); + actions.push("class-accept"); + } + + // Text fallback. + const wanted = new Set(["accept", "accept all", "allow all", "i agree", "agree", "got it"]); + for (const btn of document.querySelectorAll("button, [role='button']")) { + if (!visible(btn)) continue; + if (wanted.has(txt(btn))) { btn.click(); actions.push("text:" + txt(btn)); break; } + } + + // Force-remove the banner element regardless of whether the click "stuck", + // because role='dialog' on the Osano window confuses calendar detection. + await new Promise(r => setTimeout(r, 300)); + document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach(el => { + el.remove(); + actions.push("removed-node"); + }); + + return JSON.stringify({ ok: true, message: actions.join(", ") || "nothing to do", actions }); + })(); + """, + verifiers: [ + Verifier(name: "no .osano-cm-window visible", js: """ + return await (async () => { + const el = document.querySelector(".osano-cm-window"); + const gone = !el || !(el.offsetWidth || el.offsetHeight); + return JSON.stringify({ ok: gone, detail: gone ? "gone" : "still visible" }); + })(); + """), + Verifier(name: "no .osano-cm-dialog visible", js: """ + return await (async () => { + const el = document.querySelector(".osano-cm-dialog"); + const gone = !el || !(el.offsetWidth || el.offsetHeight); + return JSON.stringify({ ok: gone, detail: gone ? "gone" : "still visible" }); + })(); + """) + ] + ) + if !s05.ok { return fail("Step 05 failed: \(s05.message)") } + + // ---- STEP 06 ---- + _ = await jsStep( + "Dismiss marketing modal (if present)", + action: """ + return await (async () => { + const visible = el => !!(el.offsetWidth || el.offsetHeight); + const close = Array.from(document.querySelectorAll("button, [role='button']")) + .filter(visible) + .find(el => (el.getAttribute("aria-label") || "").toLowerCase() === "close dialog"); + if (close) { close.click(); return JSON.stringify({ ok: true, message: "closed dialog" }); } + return JSON.stringify({ ok: true, message: "no dialog found" }); + })(); + """, + verifiers: [] + ) + + // ---- STEP 07 ---- + let s07 = await jsStep( + "Select trip type = One Way", + action: """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const visible = el => !!(el.offsetWidth || el.offsetHeight); + const combo = Array.from(document.querySelectorAll("[role='combobox']")) + .find(el => /round trip|one way|multi city/i.test(el.textContent || "")); + if (!combo) return JSON.stringify({ ok: false, message: "trip combobox not found" }); + + const anyOption = () => Array.from(document.querySelectorAll("mat-option, [role='option']")) + .find(visible); + + const tried = []; + + // Strategy 1: plain click. + combo.scrollIntoView({ block: "center" }); + combo.click(); + tried.push("click"); + await sleep(400); + + // Strategy 2: focus + keyboard events. + if (!anyOption()) { + combo.focus && combo.focus(); + await sleep(100); + for (const key of ["Enter", " ", "ArrowDown"]) { + combo.dispatchEvent(new KeyboardEvent("keydown", { + key, + code: key === " " ? "Space" : key, + keyCode: key === "Enter" ? 13 : key === " " ? 32 : 40, + bubbles: true, cancelable: true + })); + tried.push("key:" + key); + await sleep(350); + if (anyOption()) break; + } + } + + // Strategy 3: walk __ngContext__ for a mat-select with .open(). + if (!anyOption()) { + const ctxKey = Object.keys(combo).find(k => k.startsWith("__ngContext__")); + if (ctxKey) { + for (const item of combo[ctxKey] || []) { + if (item && typeof item === "object" && typeof item.open === "function") { + try { item.open(); tried.push("ngContext.open"); } catch (_) {} + await sleep(400); + if (anyOption()) break; + } + } + } + } + + const options = Array.from(document.querySelectorAll("mat-option, [role='option']")) + .filter(visible); + if (options.length === 0) { + return JSON.stringify({ ok: false, message: "dropdown did not open", tried }); + } + + const oneWay = options.find(el => /one\\s*way/i.test(el.textContent || "")); + if (!oneWay) { + return JSON.stringify({ + ok: false, + message: "One Way option not found", + tried, + visibleOptions: options.map(o => (o.textContent || "").trim()).slice(0, 10) + }); + } + + oneWay.scrollIntoView({ block: "center" }); + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + oneWay.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + if (typeof oneWay.click === "function") oneWay.click(); + await sleep(500); + + // Angular API fallback if the click didn't commit. + const stillHasReturn = Array.from(document.querySelectorAll("input")).some(i => { + const label = (i.getAttribute("aria-label") || "").toLowerCase(); + return label.includes("return date") && (i.offsetWidth || i.offsetHeight); + }); + let usedAngular = false; + if (stillHasReturn) { + const ctxKey = Object.keys(oneWay).find(k => k.startsWith("__ngContext__")); + if (ctxKey) { + for (const item of oneWay[ctxKey] || []) { + if (item && typeof item === "object") { + if (typeof item._selectViaInteraction === "function") { + try { item._selectViaInteraction(); usedAngular = true; break; } catch (_) {} + } + if (typeof item.select === "function" && item.value !== undefined) { + try { item.select(); usedAngular = true; break; } catch (_) {} + } + } + } + } + await sleep(500); + } + + return JSON.stringify({ + ok: true, + message: "one way dispatched" + (usedAngular ? " (angular fallback)" : ""), + tried, usedAngular + }); + })(); + """, + verifiers: [ + Verifier(name: "no return-date input visible", js: """ + return await (async () => { + const still = Array.from(document.querySelectorAll("input")).some(i => { + const label = (i.getAttribute("aria-label") || "").toLowerCase(); + return label.includes("return date") && (i.offsetWidth || i.offsetHeight); + }); + return JSON.stringify({ ok: !still, detail: still ? "return-date still visible" : "hidden" }); + })(); + """), + Verifier(name: "combobox text contains 'One Way'", js: """ + return await (async () => { + const combo = Array.from(document.querySelectorAll("[role='combobox']")) + .find(el => /round trip|one way|multi city/i.test(el.textContent || "")); + const text = combo ? (combo.textContent || "").replace(/\\s+/g, " ").trim() : ""; + const ok = /one\\s*way/i.test(text); + return JSON.stringify({ ok, detail: text.slice(0, 80) }); + })(); + """) + ] + ) + if !s07.ok { return fail("Step 07 failed: \(s07.message)") } + + // ---- STEP 08 ---- + let s08 = await jsStep( + "Open origin station picker", + action: """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const stations = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']")); + if (stations.length < 2) return JSON.stringify({ ok: false, message: "found " + stations.length + " station buttons, need 2" }); + stations[0].click(); + await sleep(800); + const items = Array.from(document.querySelectorAll( + "li[role='option'].station-options__item, li[role='option']" + )).filter(el => el.offsetWidth || el.offsetHeight); + return JSON.stringify({ + ok: items.length > 0, + message: "dropdown items: " + items.length, + count: items.length + }); + })(); + """, + verifiers: [ + Verifier(name: "dropdown items visible", js: """ + return await (async () => { + const items = Array.from(document.querySelectorAll( + "li[role='option'].station-options__item, li[role='option']" + )).filter(el => el.offsetWidth || el.offsetHeight); + return JSON.stringify({ ok: items.length > 0, detail: "count=" + items.length }); + })(); + """) + ] + ) + if !s08.ok { return fail("Step 08 failed: \(s08.message)") } + + // ---- STEP 09 ---- + let s09 = await jsStep( + "Select origin = \(origin)", + action: selectStationActionJS(indexIsDestination: false), + verifiers: [ + Verifier( + name: "station button[0] contains \(origin)", + js: verifyStationButtonJS(index: 0, code: origin) + ) + ] + ) + if !s09.ok { return fail("Step 09 failed: \(s09.message)") } + + // ---- STEP 10 ---- + let s10 = await jsStep( + "Open destination station picker", + action: """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + await sleep(300); + const stations = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']")); + if (stations.length < 2) return JSON.stringify({ ok: false, message: "lost station buttons" }); + stations[1].click(); + await sleep(800); + const items = Array.from(document.querySelectorAll( + "li[role='option'].station-options__item, li[role='option']" + )).filter(el => el.offsetWidth || el.offsetHeight); + return JSON.stringify({ ok: items.length > 0, message: "dropdown items: " + items.length }); + })(); + """, + verifiers: [ + Verifier(name: "dropdown items visible", js: """ + return await (async () => { + const items = Array.from(document.querySelectorAll( + "li[role='option'].station-options__item, li[role='option']" + )).filter(el => el.offsetWidth || el.offsetHeight); + return JSON.stringify({ ok: items.length > 0, detail: "count=" + items.length }); + })(); + """) + ] + ) + if !s10.ok { return fail("Step 10 failed: \(s10.message)") } + + // ---- STEP 11 ---- + let s11 = await jsStep( + "Select destination = \(destination)", + action: selectStationActionJS(indexIsDestination: true), + verifiers: [ + Verifier( + name: "station button[1] contains \(destination)", + js: verifyStationButtonJS(index: 1, code: destination) + ) + ] + ) + if !s11.ok { return fail("Step 11 failed: \(s11.message)") } + + // ---- STEP 12 ---- + // JSX's picker renders in two phases: first the overlay shell (with + // month-name text in it), then the actual day cells a few hundred + // milliseconds later. We must wait for phase 2 — otherwise step 13 + // runs before any [aria-label="Month D, YYYY"] exists in the DOM and + // gives up. + let s12 = await jsStep( + "Open depart datepicker (wait for day cells to render)", + action: """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const input = Array.from(document.querySelectorAll("input")).find(i => { + const label = (i.getAttribute("aria-label") || "").toLowerCase(); + return label.includes("depart date") || (label.includes("date") && !label.includes("return")); + }); + if (!input) return JSON.stringify({ ok: false, message: "depart-date input not found" }); + + const wrapper = input.closest("mat-form-field, jsx-form-field, .form-field, .jsx-form-field"); + let toggle = + (wrapper && wrapper.querySelector("mat-datepicker-toggle button")) || + (wrapper && wrapper.querySelector("button[aria-label*='calendar' i]")) || + (wrapper && wrapper.querySelector("button[aria-label*='date picker' i]")) || + input; + toggle.click(); + await sleep(400); + + // Count real day cells: any visible element whose aria-label + // matches the pattern " , ". + const DATE_ARIA = /\\b(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2},?\\s+\\d{4}\\b/; + const countDayCells = () => Array.from(document.querySelectorAll("[aria-label]")) + .filter(el => (el.offsetWidth || el.offsetHeight) && DATE_ARIA.test(el.getAttribute("aria-label") || "")) + .length; + + // Poll up to 5 seconds for day cells to actually render. + const deadline = Date.now() + 5000; + let reopens = 0; + let lastCellCount = 0; + while (Date.now() < deadline) { + lastCellCount = countDayCells(); + if (lastCellCount > 0) break; + // Halfway through, try re-clicking the input in case the + // first open didn't take. + if (reopens === 0 && Date.now() - (deadline - 5000) > 2000) { + input.click(); + if (input.focus) input.focus(); + reopens++; + } + await sleep(150); + } + + return JSON.stringify({ + ok: lastCellCount > 0, + message: lastCellCount > 0 + ? ("picker open, " + lastCellCount + " day cells rendered" + (reopens ? " (reopened " + reopens + "x)" : "")) + : "picker opened but no day cells rendered within 5s", + dayCellCount: lastCellCount, + reopens + }); + })(); + """, + verifiers: [ + Verifier(name: "≥1 day-aria-label cell rendered", js: """ + return await (async () => { + const DATE_ARIA = /\\b(January|February|March|April|May|June|July|August|September|October|November|December)\\s+\\d{1,2},?\\s+\\d{4}\\b/; + const n = Array.from(document.querySelectorAll("[aria-label]")) + .filter(el => (el.offsetWidth || el.offsetHeight) && DATE_ARIA.test(el.getAttribute("aria-label") || "")) + .length; + return JSON.stringify({ ok: n > 0, detail: "day cells=" + n }); + })(); + """) + ] + ) + if !s12.ok { return fail("Step 12 failed: \(s12.message)") } + + // ---- STEP 13 ---- + let s13 = await jsStep( + "Ensure target day cell is visible (\(targetMonthName) \(targetDay), \(targetYear))", + action: navigateMonthActionJS(), + verifiers: [ + Verifier(name: "aria-label '\(targetMonthName) \(targetDay), \(targetYear)' present", js: """ + return await (async () => { + const targetMonthName = "\(targetMonthName)"; + const targetYear = \(targetYear); + const targetDay = \(targetDay); + const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear; + const cells = Array.from(document.querySelectorAll("[aria-label]")) + .filter(el => el.offsetWidth || el.offsetHeight); + const exact = cells.some(el => { + const a = (el.getAttribute("aria-label") || "").trim(); + return a === ariaExact || a.startsWith(ariaExact); + }); + if (exact) return JSON.stringify({ ok: true, detail: "exact match" }); + const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b"); + const loose = cells.some(el => { + const a = el.getAttribute("aria-label") || ""; + return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a); + }); + return JSON.stringify({ ok: loose, detail: loose ? "loose match" : "cell not found" }); + })(); + """) + ] + ) + if !s13.ok { return fail("Step 13 failed: \(s13.message)") } + + // ---- STEP 14 ---- + let s14 = await jsStep( + "Click day cell for \(targetMonthName) \(targetDay), \(targetYear) (by aria-label)", + action: clickDayCellActionJS(), + verifiers: [ + Verifier(name: "day cell click was dispatched", js: """ + return await (async () => { + // Post-condition check: the depart-date input should now have + // a value. If not, the click missed. + const input = Array.from(document.querySelectorAll("input")).find(i => { + const label = (i.getAttribute("aria-label") || "").toLowerCase(); + return label.includes("depart date"); + }); + const val = (input && input.value) || ""; + return JSON.stringify({ ok: val.length > 0, detail: "input.value=" + (val || "(empty)") }); + })(); + """) + ] + ) + if !s14.ok { return fail("Step 14 failed: \(s14.message)") } + + // ---- STEP 15 ---- + let s15 = await jsStep( + "Click DONE button to commit date", + action: """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const visible = el => !!(el.offsetWidth || el.offsetHeight); + const doneBtn = Array.from(document.querySelectorAll("button, [role='button']")) + .filter(visible) + .find(el => /^\\s*done\\s*$/i.test((el.innerText || el.textContent || "").trim())); + if (!doneBtn) { + return JSON.stringify({ ok: false, message: "DONE button not found" }); + } + doneBtn.scrollIntoView({ block: "center" }); + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + doneBtn.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + if (typeof doneBtn.click === "function") doneBtn.click(); + await sleep(600); + return JSON.stringify({ ok: true, message: "dispatched click on DONE" }); + })(); + """, + verifiers: [ + Verifier(name: "datepicker panel no longer visible", js: """ + return await (async () => { + const candidates = document.querySelectorAll( + "mat-calendar, .mat-calendar, mat-datepicker-content, [class*='mat-datepicker'], .cdk-overlay-container [class*='datepicker']" + ); + let anyVisible = false; + for (const c of candidates) { if (c.offsetWidth || c.offsetHeight) { anyVisible = true; break; } } + return JSON.stringify({ ok: !anyVisible, detail: anyVisible ? "still visible" : "closed" }); + })(); + """), + Verifier(name: "depart-date input has a value", js: """ + return await (async () => { + const inputs = Array.from(document.querySelectorAll("input")).filter(i => + (i.getAttribute("aria-label") || "").toLowerCase().includes("depart date") + ); + const withValue = inputs.filter(i => (i.value || "").length > 0); + const firstVal = (inputs[0] && inputs[0].value) || ""; + return JSON.stringify({ + ok: withValue.length > 0, + detail: "value=" + (firstVal || "(empty)") + }); + })(); + """), + Verifier(name: "only one visible depart-date input", js: """ + return await (async () => { + const visibles = Array.from(document.querySelectorAll("input")) + .filter(i => (i.getAttribute("aria-label") || "").toLowerCase().includes("depart date")) + .filter(i => i.offsetWidth || i.offsetHeight); + return JSON.stringify({ ok: visibles.length === 1, detail: "count=" + visibles.length }); + })(); + """) + ] + ) + if !s15.ok { return fail("Step 15 failed: \(s15.message)") } + + // ---- STEP 16 ---- + let s16 = await jsStep( + "Force Angular form revalidation", + action: """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + document.body.click(); + for (const i of document.querySelectorAll("input")) { + i.dispatchEvent(new Event("blur", { bubbles: true })); + } + await sleep(300); + let touched = 0; + for (const el of document.querySelectorAll("*")) { + const ctxKey = Object.keys(el).find(k => k.startsWith("__ngContext__")); + if (!ctxKey) continue; + for (const item of el[ctxKey] || []) { + if (item && typeof item === "object" && (item.controls || item.control)) { + const form = item.controls ? item : (item.control && item.control.controls ? item.control : null); + if (form) { + try { if (form.markAllAsTouched) form.markAllAsTouched(); } catch (_) {} + try { if (form.updateValueAndValidity) form.updateValueAndValidity(); } catch (_) {} + touched++; + } + } + } + if (touched > 30) break; + } + await sleep(400); + return JSON.stringify({ ok: true, message: "touched " + touched + " form controls", touched }); + })(); + """, + verifiers: [ + Verifier(name: "Find Flights button is enabled", js: """ + return await (async () => { + const btn = Array.from(document.querySelectorAll("button")) + .find(b => /find flights/i.test(b.textContent || "")); + if (!btn) return JSON.stringify({ ok: false, detail: "button not found" }); + const disabled = btn.disabled || btn.getAttribute("aria-disabled") === "true"; + return JSON.stringify({ + ok: !disabled, + detail: "disabled=" + btn.disabled + " aria-disabled=" + btn.getAttribute("aria-disabled") + }); + })(); + """) + ] + ) + if !s16.ok { return fail("Step 16 failed: \(s16.message)") } + + // ---- STEP 17 ---- + // IMPORTANT: we do NOT click the Find Flights button. In WKWebView, + // synthetic MouseEvents have `isTrusted=false`, and JSX's custom + // datepicker commits its selection into the Angular FormControl only + // on trusted user gestures. The result is: the date input shows + // "Sat, Apr 11" but the underlying FormControl is null, Angular's + // search() sees form.invalid === true, and silently bails without + // firing a request. + // + // Playwright doesn't hit this because CDP's Input.dispatchMouseEvent + // produces trusted events. WKWebView has no equivalent. + // + // Workaround: call the /availability/search/simple endpoint directly + // from within the loaded page context. We have: + // - The browser's TLS fingerprint and session cookies (so Akamai + // doesn't reject us the way it rejected Playwright's direct Chrome + // launch before we switched to connectOverCDP). + // - The anonymous auth token sitting in + // sessionStorage["navitaire.digital.token"], put there by the + // SPA's own bootstrap POST /api/nsk/v2/token call. + // - Steps 5–16 still ran (origin/dest/date UI flow), which warmed + // up the app state and triggered the lowfare/estimate preload, + // so the session is in the same state it would be after a real + // user pressed Find Flights. + let s17 = await jsStep( + "POST /availability/search/simple directly via fetch", + action: """ + return await (async () => { + // The SPA stores its anonymous auth token as JSON: + // { token: "eyJhbGci...", idleTimeoutInMinutes: 15 } + // under sessionStorage["navitaire.digital.token"]. Wait up + // to 3 seconds for it in case the SPA hasn't finished + // bootstrapping its token refresh. + const readToken = () => { + try { + const raw = sessionStorage.getItem("navitaire.digital.token"); + if (!raw) return ""; + const parsed = JSON.parse(raw); + return (parsed && parsed.token) || ""; + } catch (_) { return ""; } + }; + let token = readToken(); + if (!token) { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 200)); + token = readToken(); + if (token) break; + } + } + if (!token) { + return JSON.stringify({ + ok: false, + message: "no auth token in sessionStorage['navitaire.digital.token'] after 3s wait" + }); + } + + // Request body mirrors exactly what JSX's own search() method + // POSTs when the form is valid. Confirmed via the Playwright + // interceptor log in scripts/jsx_playwright_search.mjs. + const body = { + beginDate: "\(date)", + destination: "\(destination)", + origin: "\(origin)", + passengers: { types: [{ count: 1, type: "ADT" }] }, + taxesAndFees: 2, + filters: { + maxConnections: 4, + compressionType: 1, + sortOptions: [4], + fareTypes: ["R"], + exclusionType: 2 + }, + numberOfFaresPerJourney: 10, + codes: { currencyCode: "USD" }, + ssrCollectionsMode: 1 + }; + + try { + const resp = await fetch( + "https://api.jsx.com/api/nsk/v4/availability/search/simple", + { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + "Authorization": token + }, + body: JSON.stringify(body) + } + ); + const bodyText = await resp.text(); + // Also populate window.__jsxProbe.searchSimple so the + // subsequent verification and extraction steps (18, 19) + // can read from the same place they would have if a UI + // click had triggered the call. + if (window.__jsxProbe) { + window.__jsxProbe.searchSimple = { + url: "https://api.jsx.com/api/nsk/v4/availability/search/simple", + method: "POST", + status: resp.status, + body: bodyText, + transport: "direct-fetch" + }; + } + return JSON.stringify({ + ok: resp.status >= 200 && resp.status < 300 && bodyText.length > 0, + message: "status=" + resp.status + ", body=" + bodyText.length + " bytes", + status: resp.status, + bodyLength: bodyText.length, + tokenPrefix: token.slice(0, 24) + }); + } catch (err) { + return JSON.stringify({ + ok: false, + message: "fetch error: " + String(err && err.message ? err.message : err), + error: String(err) + }); + } + })(); + """, + verifiers: [ + Verifier(name: "searchSimple status is 2xx", js: """ + return await (async () => { + const p = (window.__jsxProbe || {}).searchSimple; + const ok = p && typeof p.status === "number" && p.status >= 200 && p.status < 300; + return JSON.stringify({ ok, detail: "status=" + (p ? p.status : "nil") }); + })(); + """), + Verifier(name: "searchSimple body is non-empty", js: """ + return await (async () => { + const p = (window.__jsxProbe || {}).searchSimple; + const len = p && p.body ? p.body.length : 0; + return JSON.stringify({ ok: len > 100, detail: len + " bytes" }); + })(); + """), + Verifier(name: "body parses as JSON with data.results", js: """ + return await (async () => { + try { + const p = (window.__jsxProbe || {}).searchSimple; + if (!p || !p.body) return JSON.stringify({ ok: false, detail: "no body" }); + const json = JSON.parse(p.body); + const ok = !!(json && json.data && Array.isArray(json.data.results)); + return JSON.stringify({ ok, detail: ok ? "data.results present" : "missing data.results" }); + } catch (e) { + return JSON.stringify({ ok: false, detail: "parse error: " + String(e) }); + } + })(); + """) + ] + ) + if !s17.ok { return fail("Step 17 failed: \(s17.message)") } + + // ---- STEP 18 ---- + let s18 = await jsStep( + "Extract searchSimple body to Swift", + action: """ + return await (async () => { + const p = (window.__jsxProbe || {}).searchSimple; + if (!p || !p.body) return JSON.stringify({ ok: false, message: "no body" }); + return JSON.stringify({ ok: true, message: "body length " + p.body.length, body: p.body }); + })(); + """, + verifiers: [] + ) + if !s18.ok { return fail("Step 18 failed: \(s18.message)") } + guard let rawBody = s18.data["body"] as? String, !rawBody.isEmpty else { + return fail("Step 18 failed: body missing from JS result") + } + self.capturedBody = rawBody + + // ---- STEP 19 ---- + var parsedFlights: [JSXFlight] = [] + let s19 = await nativeStep("Parse searchSimple into [JSXFlight]") { [weak self] in + guard let self else { return (false, "self gone", [:]) } + parsedFlights = self.parseFlights(from: rawBody) + if parsedFlights.isEmpty { + return (false, "parser returned 0 flights", ["rawLength": rawBody.count]) + } + for flight in parsedFlights { + let classesText = flight.classes.map { c in + "\(c.classOfService)/\(c.productClass):\(c.availableCount)@$\(Int(c.fareTotal))" + }.joined(separator: ", ") + let low = flight.lowestFareTotal.map { "$\(Int($0))" } ?? "n/a" + print("[JSX] │ \(flight.flightNumber) \(flight.origin)→\(flight.destination) " + + "\(flight.departureLocal) → \(flight.arrivalLocal) " + + "stops=\(flight.stops) seats=\(flight.totalAvailable) from=\(low) [\(classesText)]") + } + return (true, "decoded \(parsedFlights.count) flights", ["count": parsedFlights.count]) + } verify: { [weak self] in + guard let self else { return [] } + var results: [Verification] = [] + results.append(Verification( + name: "flights.count > 0", + passed: !parsedFlights.isEmpty, + detail: "count=\(parsedFlights.count)" + )) + let allHaveNumber = parsedFlights.allSatisfy { !$0.flightNumber.isEmpty } + results.append(Verification( + name: "every flight has a flight number", + passed: allHaveNumber, + detail: "" + )) + let expectedOrigin = self.origin + let expectedDestination = self.destination + let allMarketMatch = parsedFlights.allSatisfy { + $0.origin == expectedOrigin && $0.destination == expectedDestination + } + results.append(Verification( + name: "every flight origin/destination matches \(expectedOrigin)|\(expectedDestination)", + passed: allMarketMatch, + detail: "" + )) + return results + } + if !s19.ok { return fail("Step 19 failed: \(s19.message)") } + + logHeader("JSX FLOW COMPLETE: \(parsedFlights.count) unique flights") + self.webView = nil + return JSXSearchResult(flights: parsedFlights, rawSearchBody: rawBody, error: nil) + } + + // MARK: - Step infrastructure + + struct Verification { + let name: String + let passed: Bool + let detail: String + } + + struct Verifier { + let name: String + let js: String + } + + struct StepOutcome { + let ok: Bool + let message: String + let data: [String: Any] + let verifications: [Verification] + } + + /// JavaScript step — runs an action JS snippet, then each verifier JS snippet. + /// Every verifier is run so the log shows all assertions, even when later ones would fail. + private func jsStep( + _ name: String, + action: String, + verifiers: [Verifier] + ) async -> StepOutcome { + stepNumber += 1 + let label = "STEP \(String(format: "%02d", stepNumber))" + print("[JSX] ┌─ \(label) \(name)") + let start = Date() + + let actionResult = await runJS(action) + let actionOk = (actionResult?["ok"] as? Bool) ?? false + let actionMessage = (actionResult?["message"] as? String) ?? (actionOk ? "ok" : "failed") + var stepData: [String: Any] = [:] + if let dict = actionResult { + for (k, v) in dict where k != "ok" && k != "message" { stepData[k] = v } + } + print("[JSX] │ action: \(truncate(actionMessage))") + + var verifications: [Verification] = [] + var allOk = actionOk + for verifier in verifiers { + let res = await runJS(verifier.js) + let passed = (res?["ok"] as? Bool) ?? false + let detail = (res?["detail"] as? String) ?? "" + verifications.append(Verification(name: verifier.name, passed: passed, detail: detail)) + let glyph = passed ? "✓" : "✗" + let suffix = detail.isEmpty ? "" : " — \(truncate(detail))" + print("[JSX] │ verify \(glyph) \(verifier.name)\(suffix)") + if !passed { allOk = false } + } + + if !actionOk { + // Print any extra fields the action returned (sample arrays, + // counts, etc.) BEFORE the generic diagnostic dump. + for (k, v) in stepData.sorted(by: { $0.key < $1.key }) { + print("[JSX] │ action.\(k): \(truncate(describe(v), limit: 400))") + } + await dumpDiagnostics(reason: "action failed: \(actionMessage)") + } else if !allOk { + for (k, v) in stepData.sorted(by: { $0.key < $1.key }) { + print("[JSX] │ action.\(k): \(truncate(describe(v), limit: 400))") + } + await dumpDiagnostics(reason: "verification failed") + } + + let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start)) + let status = allOk ? "OK" : "FAIL" + print("[JSX] └─ \(label) \(status) (\(elapsed))") + return StepOutcome(ok: allOk, message: actionMessage, data: stepData, verifications: verifications) + } + + /// Render a JSON-deserialized value compactly for diagnostic logging. + private func describe(_ value: Any) -> String { + if let arr = value as? [Any] { + let items = arr.prefix(8).map { describe($0) }.joined(separator: ", ") + return "[" + items + (arr.count > 8 ? ", …\(arr.count - 8) more" : "") + "]" + } + if let dict = value as? [String: Any] { + let kv = dict.sorted { $0.key < $1.key } + .map { "\($0.key)=\(describe($0.value))" } + .joined(separator: " ") + return "{" + kv + "}" + } + return String(describing: value) + } + + /// Swift-side step — runs an async action closure, then an async verification closure. + /// Used for parsing the JSON body on the Swift side so the same reporting format applies. + private func nativeStep( + _ name: String, + action: () async -> (ok: Bool, message: String, data: [String: Any]), + verify: () async -> [Verification] + ) async -> StepOutcome { + stepNumber += 1 + let label = "STEP \(String(format: "%02d", stepNumber))" + print("[JSX] ┌─ \(label) \(name)") + let start = Date() + + let (actionOk, actionMessage, stepData) = await action() + print("[JSX] │ action: \(truncate(actionMessage))") + + let verifications = await verify() + var allOk = actionOk + for v in verifications { + let glyph = v.passed ? "✓" : "✗" + let suffix = v.detail.isEmpty ? "" : " — \(truncate(v.detail))" + print("[JSX] │ verify \(glyph) \(v.name)\(suffix)") + if !v.passed { allOk = false } + } + + let elapsed = String(format: "%.2fs", Date().timeIntervalSince(start)) + let status = allOk ? "OK" : "FAIL" + print("[JSX] └─ \(label) \(status) (\(elapsed))") + return StepOutcome(ok: allOk, message: actionMessage, data: stepData, verifications: verifications) + } + + /// Convenience used by step 02's verify closure. + fileprivate func asyncVerify(name: String, js: String) async -> Verification { + let res = await runJS(js) + let passed = (res?["ok"] as? Bool) ?? false + let detail = (res?["detail"] as? String) ?? "" + return Verification(name: name, passed: passed, detail: detail) + } + + // MARK: - JS runner + + private func runJS(_ js: String) async -> [String: Any]? { + guard let webView = self.webView else { return nil } + do { + let raw = try await webView.callAsyncJavaScript(js, contentWorld: .page) + guard let text = raw as? String else { return nil } + guard let data = text.data(using: .utf8) else { return nil } + let parsed = try JSONSerialization.jsonObject(with: data) + return parsed as? [String: Any] + } catch { + print("[JSX] │ runJS exception: \(error.localizedDescription)") + return nil + } + } + + // MARK: - Diagnostics + + private func dumpDiagnostics(reason: String) async { + print("[JSX] │ ⚠ diagnostic dump (\(reason)):") + let diag = await runJS(""" + return await (async () => { + const stationBtns = document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']").length; + const matOptions = Array.from(document.querySelectorAll("mat-option")).filter(e => e.offsetWidth || e.offsetHeight).length; + const calendar = !!document.querySelector("mat-calendar, .mat-calendar, [class*='mat-datepicker']"); + const findBtn = Array.from(document.querySelectorAll("button")).find(b => /find flights/i.test(b.textContent || "")); + const findBtnState = findBtn ? { disabled: findBtn.disabled, aria: findBtn.getAttribute("aria-disabled") } : null; + const probe = window.__jsxProbe || {}; + const shortenUrl = u => (u || "").split("?")[0].split("/").slice(-3).join("/"); + const lastCompleted = (probe.allCalls || []).slice(-6).map(c => + c.method + " " + c.status + (c.error ? "[" + c.error + "]" : "") + " " + shortenUrl(c.url) + ); + const lastInitiated = (probe.initiatedCalls || []).slice(-6).map(c => + c.method + " (init) " + shortenUrl(c.url) + ); + // Surface any error-flagged inputs so we can spot form validation failures. + const errorMarkers = Array.from(document.querySelectorAll( + "[class*='ng-invalid'], [class*='mat-form-field-invalid'], [class*='error']" + )).filter(e => e.offsetWidth || e.offsetHeight) + .slice(0, 5) + .map(e => ({ + tag: e.tagName.toLowerCase(), + cls: (e.className || "").toString().slice(0, 80), + aria: e.getAttribute("aria-label") || "" + })); + return JSON.stringify({ + ok: true, + href: location.href, + stationBtns, matOptions, calendar, + findBtnState, + lastCompleted, + lastInitiated, + errorMarkers + }); + })(); + """) + if let diag { + for (k, v) in diag where k != "ok" { + print("[JSX] │ \(k): \(v)") + } + } else { + print("[JSX] │ (diagnostic JS failed)") + } + } + + // MARK: - JS snippet builders + + private func selectStationActionJS(indexIsDestination: Bool) -> String { + let index = indexIsDestination ? 1 : 0 + let code = indexIsDestination ? destination : origin + return """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const visible = el => !!(el.offsetWidth || el.offsetHeight); + const idx = \(index); + const code = "\(code)"; + + // Type into the "Airport or city" search input if the dropdown has one. + const searchInput = Array.from(document.querySelectorAll("input")) + .filter(visible) + .find(i => (i.getAttribute("placeholder") || "").toLowerCase() === "airport or city"); + if (searchInput) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value") && Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set; + searchInput.focus(); + if (setter) setter.call(searchInput, code); else searchInput.value = code; + searchInput.dispatchEvent(new InputEvent("input", { bubbles: true, data: code })); + searchInput.dispatchEvent(new Event("change", { bubbles: true })); + await sleep(500); + } + + const items = Array.from(document.querySelectorAll( + "li[role='option'].station-options__item, li[role='option']" + )).filter(visible); + if (items.length === 0) { + return JSON.stringify({ ok: false, message: "no dropdown items" }); + } + + const pattern = new RegExp("(^|\\\\b)" + code + "(\\\\b|$)", "i"); + const match = items.find(el => pattern.test(((el.innerText || el.textContent) || "").trim())); + if (!match) { + return JSON.stringify({ + ok: false, + message: "no match for " + code, + sample: items.slice(0, 10).map(i => (i.textContent || "").trim().slice(0, 60)) + }); + } + + match.scrollIntoView({ block: "center" }); + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + match.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + if (typeof match.click === "function") match.click(); + await sleep(500); + + // Angular API fallback. + const after = () => Array.from(document.querySelectorAll("[aria-label='Station select']"))[idx]; + let finalText = (after() && after().textContent || "").trim(); + let usedAngular = false; + if (!finalText.includes(code)) { + const ctxKey = Object.keys(match).find(k => k.startsWith("__ngContext__")); + if (ctxKey) { + for (const item of match[ctxKey] || []) { + if (!item || typeof item !== "object") continue; + for (const m of ["_selectViaInteraction", "select", "onSelect", "onClick", "handleClick"]) { + if (typeof item[m] === "function") { + try { item[m](); usedAngular = true; break; } catch (_) {} + } + } + if (usedAngular) break; + } + } + await sleep(400); + finalText = (after() && after().textContent || "").trim(); + } + + return JSON.stringify({ + ok: finalText.includes(code), + message: "clicked '" + (match.textContent || "").trim().slice(0, 40) + "'" + (usedAngular ? " (angular fallback)" : ""), + finalText: finalText.slice(0, 80), + usedAngular + }); + })(); + """ + } + + private func verifyStationButtonJS(index: Int, code: String) -> String { + return """ + return await (async () => { + const btn = Array.from(document.querySelectorAll("[aria-label='Station select']"))[\(index)]; + const text = btn ? (btn.textContent || "").replace(/\\s+/g, " ").trim() : ""; + return JSON.stringify({ ok: text.includes("\(code)"), detail: text.slice(0, 80) }); + })(); + """ + } + + private func navigateMonthActionJS() -> String { + return """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const targetYear = \(targetYear); + const targetMonth = \(targetMonth); + const targetDay = \(targetDay); + const targetMonthName = "\(targetMonthName)"; + const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear; + + // JSX uses a custom date picker (NOT Angular Material's mat-calendar), + // so rather than read month headers (which use unknown markup) we + // simply poll for the target day's aria-label existing. + const findTargetCell = () => { + const cells = Array.from(document.querySelectorAll("[aria-label]")) + .filter(el => el.offsetWidth || el.offsetHeight); + // Exact aria-label first. + let cell = cells.find(el => { + const a = (el.getAttribute("aria-label") || "").trim(); + return a === ariaExact || a.startsWith(ariaExact); + }); + if (cell) return { cell, matchedBy: "exact" }; + // Loose: contains month + year + day word-boundary. + const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b"); + cell = cells.find(el => { + const a = el.getAttribute("aria-label") || ""; + return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a); + }); + return cell ? { cell, matchedBy: "loose" } : null; + }; + + const waitForTarget = async (ms) => { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const hit = findTargetCell(); + if (hit) return hit; + await sleep(150); + } + return findTargetCell(); + }; + + // Case A: target cell is already visible in the current month view. + // This is the common case for near-term dates because JSX shows + // two months at a time by default. + let hit = await waitForTarget(3000); + if (hit) { + return JSON.stringify({ + ok: true, + message: "target cell visible without navigation (" + hit.matchedBy + " match)", + attempts: 0 + }); + } + + // Case B: target is in a different month. Walk forward or back. + // Selectors are deliberately broad because JSX's picker is custom — + // neither .mat-calendar-next-button nor any Material class applies. + const findNext = () => document.querySelector( + ".mat-calendar-next-button, [aria-label*='Next month' i], [aria-label*='next' i], " + + "button[class*='next'], [class*='next-month']" + ); + const findPrev = () => document.querySelector( + ".mat-calendar-previous-button, [aria-label*='Previous month' i], [aria-label*='prev' i], " + + "button[class*='prev'], [class*='prev-month']" + ); + + const now = new Date(); + const currentAbs = now.getFullYear() * 12 + now.getMonth(); + const targetAbs = targetYear * 12 + (targetMonth - 1); + const forward = currentAbs <= targetAbs; + + let attempts = 0; + for (let i = 0; i < 24; i++) { + attempts++; + const btn = forward ? findNext() : findPrev(); + if (!btn || btn.disabled) { + return JSON.stringify({ + ok: false, + message: "target not visible and no " + (forward ? "next" : "prev") + " button", + attempts + }); + } + btn.click(); + hit = await waitForTarget(1500); + if (hit) { + return JSON.stringify({ + ok: true, + message: "navigated " + attempts + " step(s), target cell present (" + hit.matchedBy + " match)", + attempts + }); + } + } + + return JSON.stringify({ + ok: false, + message: "target cell not found after " + attempts + " navigation attempts", + attempts + }); + })(); + """ + } + + private func clickDayCellActionJS() -> String { + return """ + return await (async () => { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + + // IMPORTANT: search the whole document, not a scoped calendar + // container. JSX's custom picker has no predictable wrapper class, + // and step 13 already confirmed the target aria-label is present + // in the global document. Scoping here causes a mismatch where + // step 13 finds the cell and step 14 doesn't. + const targetMonthName = "\(targetMonthName)"; + const targetYear = \(targetYear); + const targetDay = \(targetDay); + const ariaExact = targetMonthName + " " + targetDay + ", " + targetYear; + const ariaLoose = targetMonthName + " " + targetDay + " " + targetYear; + + const ariaCells = Array.from(document.querySelectorAll("[aria-label]")) + .filter(el => el.offsetWidth || el.offsetHeight); + + // Primary: exact aria-label match. + let cell = ariaCells.find(el => { + const a = (el.getAttribute("aria-label") || "").trim(); + return a === ariaExact || a === ariaLoose || a.startsWith(ariaExact); + }); + let matchedBy = cell ? "aria-exact" : null; + + // Secondary: aria-label contains month, year, and day as a word + // boundary. This is what step 13's loose match uses. + if (!cell) { + const dayRe = new RegExp("\\\\b" + targetDay + "\\\\b"); + cell = ariaCells.find(el => { + const a = el.getAttribute("aria-label") || ""; + return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a); + }); + if (cell) matchedBy = "aria-loose"; + } + + if (!cell) { + // Collect diagnostics: all visible aria-labels that contain + // the month name (so we can see how JSX actually formats them). + const monthMatches = ariaCells + .filter(el => (el.getAttribute("aria-label") || "").includes(targetMonthName)) + .slice(0, 15) + .map(el => ({ + tag: el.tagName.toLowerCase(), + role: el.getAttribute("role") || "", + aria: el.getAttribute("aria-label"), + disabled: el.getAttribute("aria-disabled") || el.disabled || false + })); + return JSON.stringify({ + ok: false, + message: "day cell not found for '" + ariaExact + "' (ariaCells=" + ariaCells.length + ", month matches=" + monthMatches.length + ")", + ariaExact, + ariaCellCount: ariaCells.length, + monthMatches + }); + } + + // Sanity check: is the cell disabled? JSX marks past-date cells as + // aria-disabled="true" — clicking them won't commit the selection. + const disabled = cell.getAttribute("aria-disabled") === "true" + || cell.getAttribute("disabled") !== null + || cell.classList.contains("disabled") + || cell.classList.contains("mat-calendar-body-disabled"); + if (disabled) { + return JSON.stringify({ + ok: false, + message: "target day cell is disabled (" + matchedBy + "): " + (cell.getAttribute("aria-label") || ""), + matchedBy, + ariaLabel: cell.getAttribute("aria-label"), + className: (cell.className || "").toString().slice(0, 200) + }); + } + + cell.scrollIntoView({ block: "center" }); + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + cell.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + if (typeof cell.click === "function") cell.click(); + await sleep(500); + + return JSON.stringify({ + ok: true, + message: "clicked day cell (" + matchedBy + ") aria='" + (cell.getAttribute("aria-label") || "") + "'", + matchedBy, + clickedAriaLabel: cell.getAttribute("aria-label") || "" + }); + })(); + """ + } + + // MARK: - Parsing + + /// Walk the Navitaire availability payload and extract one `JSXFlight` per + /// unique journey in `data.results[].trips[].journeysAvailableByMarket["ORG|DST"][]`. + /// Joins each `journey.fares[].fareAvailabilityKey` to the top-level + /// `data.faresAvailable[]` record for price + class-of-service. + fileprivate func parseFlights(from rawJSON: String) -> [JSXFlight] { + guard let data = rawJSON.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let payload = root["data"] as? [String: Any] else { + print("[JSX] │ parseFlights: root JSON not a dict with 'data'") + return [] + } + + let faresAvailable = payload["faresAvailable"] as? [String: Any] ?? [:] + let results = payload["results"] as? [[String: Any]] ?? [] + let marketKey = "\(origin)|\(destination)" + + var flights: [JSXFlight] = [] + var seenKeys = Set() + + for result in results { + let trips = result["trips"] as? [[String: Any]] ?? [] + for trip in trips { + let byMarket = trip["journeysAvailableByMarket"] as? [String: Any] ?? [:] + + // Prefer the market that exactly matches our route, but fall back to + // every market key in case JSX uses multi-origin/multi-dest notation. + var candidateKeys: [String] = [] + if byMarket[marketKey] != nil { candidateKeys.append(marketKey) } + for k in byMarket.keys where k != marketKey { candidateKeys.append(k) } + + for key in candidateKeys { + let journeys = byMarket[key] as? [[String: Any]] ?? [] + for journey in journeys { + if let flight = buildFlight( + journey: journey, + fallbackMarketKey: key, + faresAvailable: faresAvailable + ) { + let dedupeKey = "\(flight.flightNumber)|\(flight.departureLocal)" + if seenKeys.insert(dedupeKey).inserted { + flights.append(flight) + } + } + } + } + } + } + + // Sort by departure local time for stable logging/UI. + flights.sort { $0.departureLocal < $1.departureLocal } + return flights + } + + private func buildFlight( + journey: [String: Any], + fallbackMarketKey: String, + faresAvailable: [String: Any] + ) -> JSXFlight? { + let segments = journey["segments"] as? [[String: Any]] ?? [] + guard !segments.isEmpty else { return nil } + + // Flight number pieces come from segments[0].identifier. For multi-segment + // journeys we concatenate the segment flight numbers so the dedupe key is unique. + let firstSeg = segments[0] + let lastSeg = segments[segments.count - 1] + + let firstIdentifier = firstSeg["identifier"] as? [String: Any] ?? [:] + let carrierCode = (firstIdentifier["carrierCode"] as? String) ?? "" + let baseNumber = (firstIdentifier["identifier"] as? String) ?? "" + var fullFlightNumber = "\(carrierCode)\(baseNumber)" + if segments.count > 1 { + let extras = segments.dropFirst().compactMap { seg -> String? in + let ident = seg["identifier"] as? [String: Any] ?? [:] + let cc = (ident["carrierCode"] as? String) ?? "" + let num = (ident["identifier"] as? String) ?? "" + let combined = "\(cc)\(num)" + return combined.isEmpty ? nil : combined + } + if !extras.isEmpty { + fullFlightNumber += "+" + extras.joined(separator: "+") + } + } + + // Designators: prefer the journey-level designator if present; fall back to segments. + let journeyDesignator = journey["designator"] as? [String: Any] + let firstDesignator = firstSeg["designator"] as? [String: Any] ?? [:] + let lastDesignator = lastSeg["designator"] as? [String: Any] ?? [:] + + let originCode = (journeyDesignator?["origin"] as? String) + ?? (firstDesignator["origin"] as? String) + ?? marketOrigin(fallbackMarketKey) + ?? "" + let destinationCode = (journeyDesignator?["destination"] as? String) + ?? (lastDesignator["destination"] as? String) + ?? marketDestination(fallbackMarketKey) + ?? "" + let departureLocal = (journeyDesignator?["departure"] as? String) + ?? (firstDesignator["departure"] as? String) + ?? "" + let arrivalLocal = (journeyDesignator?["arrival"] as? String) + ?? (lastDesignator["arrival"] as? String) + ?? "" + + let stops = (journey["stops"] as? Int) ?? ((journey["stops"] as? NSNumber)?.intValue ?? 0) + + // Equipment type lives on segments[0].legs[0].legInfo.equipmentType. + var equipmentType: String? + if let legs = firstSeg["legs"] as? [[String: Any]], let firstLeg = legs.first, + let legInfo = firstLeg["legInfo"] as? [String: Any] { + equipmentType = legInfo["equipmentType"] as? String + } + + // Build per-class breakdown by joining journey.fares[] → data.faresAvailable[key]. + var classes: [JSXFareClass] = [] + let journeyFares = journey["fares"] as? [[String: Any]] ?? [] + for fareEntry in journeyFares { + guard let key = fareEntry["fareAvailabilityKey"] as? String else { continue } + guard let record = faresAvailable[key] as? [String: Any] else { continue } + + // availableCount is the sum of details[].availableCount for this fare bucket. + var availableCount = 0 + if let details = fareEntry["details"] as? [[String: Any]] { + for detail in details { + if let n = detail["availableCount"] as? Int { availableCount += n } + else if let n = (detail["availableCount"] as? NSNumber)?.intValue { availableCount += n } + } + } + + let totals = record["totals"] as? [String: Any] ?? [:] + let fareTotal = doubleFromJSON(totals["fareTotal"]) ?? 0 + let revenueTotal = doubleFromJSON(totals["revenueTotal"]) ?? 0 + + let recordFares = record["fares"] as? [[String: Any]] ?? [] + let primary = recordFares.first ?? [:] + let classOfService = (primary["classOfService"] as? String) ?? "" + let productClass = (primary["productClass"] as? String) ?? "" + let fareBasis = primary["fareBasisCode"] as? String + + classes.append(JSXFareClass( + classOfService: classOfService, + productClass: productClass, + availableCount: availableCount, + fareTotal: fareTotal, + revenueTotal: revenueTotal, + fareBasisCode: fareBasis + )) + } + + let totalAvailable = classes.reduce(0) { $0 + $1.availableCount } + let lowestFareTotal = classes.map { $0.fareTotal }.filter { $0 > 0 }.min() + + return JSXFlight( + flightNumber: fullFlightNumber, + carrierCode: carrierCode, + origin: originCode, + destination: destinationCode, + departureLocal: departureLocal, + arrivalLocal: arrivalLocal, + stops: stops, + equipmentType: equipmentType, + totalAvailable: totalAvailable, + lowestFareTotal: lowestFareTotal, + classes: classes + ) + } + + private func marketOrigin(_ key: String) -> String? { + key.split(separator: "|").first.map(String.init) + } + + private func marketDestination(_ key: String) -> String? { + let parts = key.split(separator: "|") + return parts.count >= 2 ? String(parts[1]) : nil + } + + private func doubleFromJSON(_ value: Any?) -> Double? { + if let d = value as? Double { return d } + if let n = value as? NSNumber { return n.doubleValue } + if let i = value as? Int { return Double(i) } + if let s = value as? String { return Double(s) } + return nil + } + + // MARK: - Navigation + + private func navigateAndWait(_ url: URL) async -> Bool { + guard let webView = self.webView else { return false } + return await withCheckedContinuation { (cont: CheckedContinuation) in + let delegate = NavDelegate(continuation: cont) + webView.navigationDelegate = delegate + webView.load(URLRequest(url: url)) + objc_setAssociatedObject(webView, "jsxNavDelegate", delegate, .OBJC_ASSOCIATION_RETAIN) + } + } + + // MARK: - Logging helpers + + private func logHeader(_ msg: String) { + print("[JSX] ══════════════════════════════════════════════════════════") + print("[JSX] \(msg)") + print("[JSX] ══════════════════════════════════════════════════════════") + } + + private func truncate(_ s: String, limit: Int = 180) -> String { + s.count <= limit ? s : "\(s.prefix(limit))… (\(s.count) chars)" + } + + private func fail(_ reason: String) -> JSXSearchResult { + logHeader("JSX FLOW FAILED: \(reason)") + self.webView = nil + return JSXSearchResult(flights: [], rawSearchBody: capturedBody, error: reason) + } +} + +// MARK: - Navigation delegate + +private final class NavDelegate: NSObject, WKNavigationDelegate { + private var continuation: CheckedContinuation? + init(continuation: CheckedContinuation) { self.continuation = continuation } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + continuation?.resume(returning: true); continuation = nil + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + print("[JSX] navigation failed: \(error.localizedDescription)") + continuation?.resume(returning: false); continuation = nil + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + print("[JSX] provisional navigation failed: \(error.localizedDescription)") + continuation?.resume(returning: false); continuation = nil + } +} diff --git a/scripts/jsx_playwright_search.mjs b/scripts/jsx_playwright_search.mjs new file mode 100644 index 0000000..ace36b6 --- /dev/null +++ b/scripts/jsx_playwright_search.mjs @@ -0,0 +1,843 @@ +#!/usr/bin/env node +// JSX one-way flight search + flight-load capture via Playwright. +// +// Usage: +// npm i -D playwright +// npx playwright install chromium +// node scripts/jsx_playwright_search.mjs --origin DAL --destination HOU --date 2026-04-15 +// +// Env / flags: +// --origin IATA (default: DAL) +// --destination IATA (default: HOU) +// --date YYYY-MM-DD (default: 2026-04-15) +// --headful show the browser (default: headless) +// --out DIR where to write captured JSON (default: /tmp/jsx-playwright) +// +// What it does: +// 1. Opens https://www.jsx.com/ in a real Chromium (Akamai/JSX blocks plain +// fetches — the browser session is what makes the API call work). +// 2. Dismisses consent + marketing popups. +// 3. Selects "One way" from the mat-select trip type dropdown. +// 4. Picks origin station, destination station, depart date from the UI. +// 5. Clicks "Find Flights". +// 6. Listens for api.jsx.com responses and captures the body of +// POST /api/nsk/v4/availability/search/simple — that payload is what +// the JSX website uses to render seat counts. Flight loads live there +// as journey.segments[].legs[].legInfo and journey.fares[].paxFares[] +// plus the per-journey `seatsAvailable` field. +// 7. Also grabs the low-fare estimate response for sanity. + +import { chromium } from "playwright"; +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { setTimeout as delay } from "node:timers/promises"; + +// ---------- CLI ---------- +function parseArgs(argv) { + const out = {}; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (!a.startsWith("--")) continue; + const key = a.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith("--")) { + out[key] = true; + } else { + out[key] = next; + i++; + } + } + return out; +} + +const args = parseArgs(process.argv); +const origin = (args.origin || "DAL").toUpperCase(); +const destination = (args.destination || "HOU").toUpperCase(); +const date = args.date || "2026-04-15"; +const headless = !args.headful; +const outDir = args.out || "/tmp/jsx-playwright"; + +const [yearStr, monthStr, dayStr] = date.split("-"); +const targetYear = Number(yearStr); +const targetMonth = Number(monthStr); // 1-12 +const targetDay = Number(dayStr); +const monthNames = [ + "January","February","March","April","May","June", + "July","August","September","October","November","December", +]; +const targetMonthName = monthNames[targetMonth - 1]; + +if (!targetYear || !targetMonth || !targetDay) { + console.error(`Invalid --date '${date}', expected YYYY-MM-DD`); + process.exit(2); +} + +await mkdir(outDir, { recursive: true }); + +// ---------- Tiny logger ---------- +let stepCounter = 0; +function step(label) { + stepCounter++; + const prefix = `[${String(stepCounter).padStart(2, "0")}]`; + const started = Date.now(); + console.log(`${prefix} ▶ ${label}`); + return (detail) => { + const ms = Date.now() - started; + const d = detail ? ` — ${detail}` : ""; + console.log(`${prefix} ✓ ${label} (${ms}ms)${d}`); + }; +} + +// ---------- Main ---------- +// IMPORTANT: Akamai Bot Manager (used by JSX via api.jsx.com) detects +// Playwright's `launch()`/`launchPersistentContext()` and returns +// ERR_HTTP2_PROTOCOL_ERROR on POST /availability/search/simple — even when +// using `channel: "chrome"`. The page, the token call, and the GET lowfare +// estimate all succeed, but the sensitive POST is blocked. +// +// The working approach: spawn REAL Chrome ourselves (identical to the +// existing jsx_cdp_probe.mjs pattern) and have Playwright attach over CDP. +// Plain-spawned Chrome has the exact TLS+HTTP/2 fingerprint Akamai expects +// from a real user, and Playwright is only used as the scripting interface. + +const chromePath = + args["chrome-path"] || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +const cdpPort = Number(args["cdp-port"] || 9231); +const userDataDir = args["user-data-dir"] || join(homedir(), ".cache", "jsx-playwright-profile"); +await mkdir(userDataDir, { recursive: true }); + +function launchChromeProcess() { + const chromeArgs = [ + `--remote-debugging-port=${cdpPort}`, + `--user-data-dir=${userDataDir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-default-apps", + "--disable-popup-blocking", + "--window-size=1440,1200", + "about:blank", + ]; + if (headless) chromeArgs.unshift("--headless=new"); + console.log(`[chrome] launching: ${chromePath} on port ${cdpPort}${headless ? " (headless)" : ""}`); + const child = spawn(chromePath, chromeArgs, { detached: false, stdio: ["ignore", "pipe", "pipe"] }); + // Silence Chrome's noisy startup/updater output — it writes tons of + // VERBOSE/ERROR lines to stderr during normal operation. Swallow them + // unless --debug-chrome is passed. + if (args["debug-chrome"]) { + child.stdout.on("data", (b) => { const t = b.toString().trim(); if (t) console.log(`[chrome.out] ${t.slice(0, 200)}`); }); + child.stderr.on("data", (b) => { const t = b.toString().trim(); if (t) console.log(`[chrome.err] ${t.slice(0, 200)}`); }); + } else { + child.stdout.on("data", () => {}); + child.stderr.on("data", () => {}); + } + return child; +} + +async function waitForCdp() { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + try { + const r = await fetch(`http://127.0.0.1:${cdpPort}/json/version`); + if (r.ok) return await r.json(); + } catch {} + await delay(250); + } + throw new Error("Chrome remote debugger never came up"); +} + +const chromeProc = launchChromeProcess(); +const cdpInfo = await waitForCdp(); +console.log(`[chrome] CDP ready: ${cdpInfo.Browser}`); + +const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); +const contexts = browser.contexts(); +const context = contexts[0] || await browser.newContext(); +const existingPages = context.pages(); +const page = existingPages[0] || await context.newPage(); +page.on("console", (msg) => { + const t = msg.type(); + if (t === "error" || t === "warning") { + console.log(` [page.${t}] ${msg.text().slice(0, 200)}`); + } +}); +page.on("pageerror", (err) => console.log(` [pageerror] ${err.message}`)); + +// Intercept JSX API responses. We want the body of search/simple (flight +// loads) and lowfare/estimate (per-day cheapest fare). Playwright exposes the +// response body via response.body() — no CDP plumbing needed. +const captured = { + searchSimple: null, + lowFare: null, + allCalls: [], +}; + +page.on("request", (request) => { + const url = request.url(); + if (url.includes("/availability/search/simple")) { + console.log(` [outgoing request] ${request.method()} ${url}`); + const post = request.postData(); + if (post) console.log(` [outgoing body] ${post.slice(0, 800)}`); + const headers = request.headers(); + console.log(` [outgoing auth] ${headers.authorization ? headers.authorization.slice(0, 30) + "..." : "NONE"}`); + } +}); +page.on("requestfailed", (request) => { + if (request.url().includes("api.jsx.com")) { + console.log(` [request failed] ${request.method()} ${request.url()} — ${request.failure()?.errorText}`); + } +}); +page.on("response", async (response) => { + const url = response.url(); + if (!url.includes("api.jsx.com")) return; + const req = response.request(); + const entry = { + url, + method: req.method(), + status: response.status(), + }; + captured.allCalls.push(entry); + try { + if (url.includes("/availability/search/simple")) { + const bodyText = await response.text(); + captured.searchSimple = { ...entry, body: bodyText }; + console.log(` ↳ captured search/simple (${bodyText.length} bytes, status ${response.status()})`); + } else if (url.includes("lowfare/estimate")) { + const bodyText = await response.text(); + captured.lowFare = { ...entry, body: bodyText }; + console.log(` ↳ captured lowfare/estimate (${bodyText.length} bytes)`); + } + } catch (err) { + console.log(` ↳ failed reading body for ${url}: ${err.message}`); + } +}); + +try { + // ---------- 1. Navigate ---------- + let done = step("Navigate to https://www.jsx.com/"); + await page.goto("https://www.jsx.com/", { waitUntil: "domcontentloaded" }); + // Wait for network to settle so Angular bootstraps, token call completes, + // and the search form is fully initialized before we touch it. + try { + await page.waitForLoadState("networkidle", { timeout: 15000 }); + } catch {} + await page.waitForTimeout(2000); + done(`url=${page.url()}`); + + // ---------- 2. Consent + popup ---------- + done = step("Dismiss consent / marketing popups"); + const consentResult = await page.evaluate(() => { + const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + const txt = (el) => (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().toLowerCase(); + const actions = []; + + // Osano cookie banner — class-based match is more reliable than text. + const osanoAccept = document.querySelector( + ".osano-cm-accept-all, .osano-cm-accept, button.osano-cm-button[class*='accept']" + ); + if (osanoAccept && visible(osanoAccept)) { + osanoAccept.click(); + actions.push("osano-accept"); + } + + // Generic consent buttons by text. + const wanted = new Set(["accept", "accept all", "allow all", "i agree", "agree", "got it"]); + for (const btn of document.querySelectorAll("button, [role='button']")) { + if (!visible(btn)) continue; + if (wanted.has(txt(btn))) { btn.click(); actions.push(`text:${txt(btn)}`); break; } + } + + // Marketing modal close. + const close = Array.from(document.querySelectorAll("button, [role='button']")) + .filter(visible) + .find((el) => (el.getAttribute("aria-label") || "").toLowerCase() === "close dialog"); + if (close) { close.click(); actions.push("close-dialog"); } + + return { actions }; + }); + await page.waitForTimeout(800); + // Verify the Osano banner is actually gone — it tends to block pointer events. + const osanoStillVisible = await page.evaluate(() => { + const el = document.querySelector(".osano-cm-window, .osano-cm-dialog"); + if (!el) return false; + return !!(el.offsetWidth || el.offsetHeight); + }); + if (osanoStillVisible) { + // Hard-kill it so it doesn't intercept clicks or match selectors. + await page.evaluate(() => { + document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach((el) => el.remove()); + }); + consentResult.actions.push("osano-force-removed"); + } + done(JSON.stringify(consentResult)); + + // ---------- 3. One way ---------- + // JSX uses Angular Material mat-select. Open via keyboard/ArrowDown, then + // click the mat-option. This mirrors the battle-tested Swift flow. + done = step("Select trip type = One way"); + const oneWayResult = await page.evaluate(async () => { + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const combo = Array.from(document.querySelectorAll("[role='combobox']")) + .find((el) => /round trip|one way|multi city/i.test(el.textContent || "")); + if (!combo) return { ok: false, reason: "trip-combobox-not-found" }; + + const anyOption = () => document.querySelector("mat-option, [role='option']"); + + // Strategy 1: plain click. + combo.scrollIntoView?.({ block: "center" }); + combo.click(); + await sleep(400); + + // Strategy 2: focus + keyboard events. + if (!anyOption()) { + combo.focus?.(); + await sleep(100); + for (const key of ["Enter", " ", "ArrowDown"]) { + combo.dispatchEvent(new KeyboardEvent("keydown", { + key, code: key === " " ? "Space" : key, keyCode: key === "Enter" ? 13 : key === " " ? 32 : 40, + bubbles: true, cancelable: true, + })); + await sleep(350); + if (anyOption()) break; + } + } + + // Strategy 3: walk __ngContext__ and call mat-select's .open(). + if (!anyOption()) { + const ctxKey = Object.keys(combo).find((k) => k.startsWith("__ngContext__")); + if (ctxKey) { + for (const item of combo[ctxKey] || []) { + if (item && typeof item === "object" && typeof item.open === "function") { + try { item.open(); } catch {} + await sleep(400); + if (anyOption()) break; + } + } + } + } + + const options = Array.from(document.querySelectorAll("mat-option, [role='option']")) + .filter((el) => el.offsetWidth || el.offsetHeight); + const oneWay = options.find((el) => /one\s*way/i.test(el.textContent || "")); + if (!oneWay) return { ok: false, reason: "one-way-option-not-found", visible: options.map((o) => o.textContent.trim()) }; + + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + oneWay.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + oneWay.click?.(); + await sleep(500); + + // Verify: return-date input should no longer be visible. + const returnVisible = Array.from(document.querySelectorAll("input")) + .some((i) => (i.getAttribute("aria-label") || "").toLowerCase().includes("return date") + && (i.offsetWidth || i.offsetHeight)); + return { ok: !returnVisible, returnVisible }; + }); + done(JSON.stringify(oneWayResult)); + if (!oneWayResult.ok) throw new Error(`Trip type selection failed: ${JSON.stringify(oneWayResult)}`); + + // ---------- 4. Origin ---------- + done = step(`Select origin = ${origin}`); + await selectStation(page, 0, origin); + done(); + + // ---------- 5. Destination ---------- + done = step(`Select destination = ${destination}`); + await selectStation(page, 1, destination); + done(); + + // ---------- 6. Date ---------- + done = step(`Set depart date = ${date}`); + const dateResult = await page.evaluate(async ({ targetYear, targetMonth, targetDay, targetMonthName }) => { + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const input = Array.from(document.querySelectorAll("input")).find((i) => { + const label = (i.getAttribute("aria-label") || "").toLowerCase(); + return label.includes("depart date") || (label.includes("date") && !label.includes("return")); + }); + if (!input) return { ok: false, reason: "depart-input-not-found" }; + + // Open datepicker: prefer an adjacent mat-datepicker-toggle, else click input. + const wrapper = input.closest("mat-form-field, jsx-form-field, .form-field, .jsx-form-field"); + let toggle = + wrapper?.querySelector("mat-datepicker-toggle button") || + wrapper?.querySelector("button[aria-label*='calendar' i]") || + wrapper?.querySelector("button[aria-label*='date picker' i]") || + input; + toggle.click(); + await sleep(800); + + // Look specifically for the JSX/Material calendar — NOT any [role='dialog'] + // because the Osano cookie banner also uses role="dialog". + // JSX uses a custom component; we also search cdk-overlay-container which + // is where Angular Material mounts datepicker overlays. + const findCalendar = () => { + const direct = document.querySelector( + "mat-calendar, .mat-calendar, mat-datepicker-content, [class*='mat-datepicker']" + ); + if (direct) return direct; + const overlay = document.querySelector(".cdk-overlay-container"); + if (overlay) { + // Any visible overlay pane that contains month-name text or day-of-week header. + const panes = overlay.querySelectorAll(".cdk-overlay-pane, [class*='datepicker'], [class*='calendar'], [class*='date-picker']"); + for (const p of panes) { + if (!(p.offsetWidth || p.offsetHeight)) continue; + const text = p.textContent || ""; + if (/january|february|march|april|may|june|july|august|september|october|november|december/i.test(text)) { + return p; + } + } + } + // Last resort: any visible element with month + year text that also has a short-day header. + const candidates = Array.from(document.querySelectorAll("div, section, article")) + .filter((el) => el.offsetWidth && el.offsetHeight && el.offsetWidth < 1000) + .filter((el) => /\b(Sun|Mon|Tue|Wed|Thu|Fri|Sat)\b/.test(el.textContent || "")) + .filter((el) => /january|february|march|april|may|june|july|august|september|october|november|december/i.test(el.textContent || "")); + return candidates[0] || null; + }; + let calendar = findCalendar(); + if (!calendar) { input.click(); input.focus?.(); await sleep(500); calendar = findCalendar(); } + if (!calendar) { + // Dump what IS visible so we can adapt. + const overlayDump = (() => { + const overlay = document.querySelector(".cdk-overlay-container"); + if (!overlay) return "no cdk-overlay-container"; + return Array.from(overlay.children).map((c) => ({ + tag: c.tagName.toLowerCase(), + cls: (c.className || "").toString().slice(0, 80), + visible: !!(c.offsetWidth || c.offsetHeight), + preview: (c.textContent || "").replace(/\s+/g, " ").trim().slice(0, 100), + })); + })(); + return { ok: false, reason: "datepicker-did-not-open", overlayDump }; + } + + const next = calendar.querySelector(".mat-calendar-next-button, [aria-label*='Next month' i]"); + const prev = calendar.querySelector(".mat-calendar-previous-button, [aria-label*='Previous month' i]"); + const monthIndex = (name) => + ["january","february","march","april","may","june","july","august","september","october","november","december"] + .indexOf(name.toLowerCase()); + + for (let i = 0; i < 24; i++) { + const header = calendar.querySelector(".mat-calendar-period-button, [class*='calendar-header']"); + const text = header?.textContent?.trim() || ""; + if (text.includes(targetMonthName) && text.includes(String(targetYear))) break; + const m = text.match(/(\w+)\s+(\d{4})/); + let forward = true; + if (m) { + const cur = parseInt(m[2], 10) * 12 + monthIndex(m[1]); + const tgt = targetYear * 12 + (targetMonth - 1); + forward = cur < tgt; + } + const btn = forward ? next : prev; + if (!btn || btn.disabled) break; + btn.click(); + await sleep(250); + } + + // Match the day cell by aria-label. JSX/Angular Material day cells have + // aria-labels like "April 15, 2026" — unambiguous across the two-month + // range picker and resilient to missed month navigation. + const ariaTarget = `${targetMonthName} ${targetDay}, ${targetYear}`; + const ariaTargetLoose = `${targetMonthName} ${targetDay} ${targetYear}`; + const allCells = Array.from(calendar.querySelectorAll("[aria-label]")) + .filter((el) => el.offsetWidth || el.offsetHeight); + let cell = allCells.find((el) => { + const a = (el.getAttribute("aria-label") || "").trim(); + return a === ariaTarget || a === ariaTargetLoose || a.startsWith(ariaTarget); + }); + + // Fallback: find any element whose aria-label includes the month name, + // the year, and the exact day number (e.g. "Wednesday, April 15, 2026"). + if (!cell) { + const dayRe = new RegExp(`\\b${targetDay}\\b`); + cell = allCells.find((el) => { + const a = (el.getAttribute("aria-label") || ""); + return a.includes(targetMonthName) && a.includes(String(targetYear)) && dayRe.test(a); + }); + } + + // Last resort: text-based match restricted to an element whose closest + // header says the target month. + if (!cell) { + const textCells = Array.from(calendar.querySelectorAll( + ".mat-calendar-body-cell, [class*='calendar-body-cell'], [class*='day-cell'], " + + "td[role='gridcell'], [role='gridcell'], button[class*='day'], div[class*='day'], span[class*='day']" + )).filter((el) => el.offsetWidth || el.offsetHeight); + cell = textCells.find((c) => { + const text = ((c.innerText || c.textContent || "").trim()); + if (text !== String(targetDay)) return false; + // Walk up looking for a header that contains the target month. + let parent = c.parentElement; + for (let i = 0; i < 8 && parent; i++, parent = parent.parentElement) { + const header = parent.querySelector?.( + "[class*='month-header'], [class*='calendar-header'], .mat-calendar-period-button, h2, h3, thead" + ); + if (header && header.textContent?.includes(targetMonthName) && header.textContent?.includes(String(targetYear))) { + return true; + } + } + return false; + }); + } + + if (!cell) { + const ariaSample = allCells.slice(0, 30).map((el) => ({ + tag: el.tagName.toLowerCase(), + aria: el.getAttribute("aria-label"), + })); + return { ok: false, reason: "day-cell-not-found", ariaTarget, ariaSample }; + } + + cell.scrollIntoView?.({ block: "center" }); + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + cell.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + cell.click?.(); + await sleep(700); + + // JSX custom datepicker has a "DONE" button that must be clicked to + // commit the selection — without it, the underlying Angular FormControl + // never gets the new value and the Find Flights submit stays no-op. + const doneBtn = Array.from(document.querySelectorAll("button, [role='button']")) + .filter((el) => el.offsetWidth || el.offsetHeight) + .find((el) => /^\s*done\s*$/i.test((el.innerText || el.textContent || "").trim())); + let doneClicked = false; + if (doneBtn) { + doneBtn.scrollIntoView?.({ block: "center" }); + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + doneBtn.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + doneBtn.click?.(); + doneClicked = true; + await sleep(600); + } + + // Force Angular form update (blur everything, mark controls touched). + document.body.click(); + for (const i of document.querySelectorAll("input")) { + i.dispatchEvent(new Event("blur", { bubbles: true })); + } + await sleep(400); + + return { ok: true, value: input.value, doneClicked }; + }, { targetYear, targetMonth, targetDay, targetMonthName }); + done(JSON.stringify(dateResult)); + if (!dateResult.ok) throw new Error(`Date selection failed: ${JSON.stringify(dateResult)}`); + + // ---------- 6b. Force Angular form revalidation ---------- + done = step("Force Angular form update"); + await page.evaluate(async () => { + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + // Blur everything — most Angular reactive forms recompute validity on blur. + document.body.click(); + for (const input of document.querySelectorAll("input")) { + input.dispatchEvent(new Event("blur", { bubbles: true })); + } + await sleep(300); + // Walk __ngContext__ and call markAllAsTouched + updateValueAndValidity on + // every FormGroup/FormControl we can find. This is the exact trick that + // works in JSXWebViewFetcher.swift. + let touched = 0; + for (const el of document.querySelectorAll("*")) { + const ctxKey = Object.keys(el).find((k) => k.startsWith("__ngContext__")); + if (!ctxKey) continue; + for (const item of el[ctxKey] || []) { + if (item && typeof item === "object" && (item.controls || item.control)) { + const form = item.controls ? item : item.control?.controls ? item.control : null; + if (form) { + try { form.markAllAsTouched?.(); form.updateValueAndValidity?.(); touched++; } catch {} + } + } + } + if (touched > 20) break; + } + await sleep(400); + }); + done(); + + // ---------- 7. Find Flights + wait for search/simple in parallel ---------- + done = step("Click Find Flights (and wait for search/simple response)"); + // Start waiting BEFORE we click so we don't miss the fast response. + const searchResponsePromise = page + .waitForResponse( + (r) => r.url().includes("/availability/search/simple") && r.request().method() === "POST", + { timeout: 25000 } + ) + .catch(() => null); + + // Use Playwright's locator.click() — waits for the button to be enabled + // and retries internally. Retry up to 3 times in case Angular's form state + // isn't ready on the first attempt. + const findBtn = page.getByRole("button", { name: /find flights/i }).first(); + let clicked = false; + let lastErr = null; + for (let attempt = 1; attempt <= 3 && !clicked; attempt++) { + try { + await findBtn.waitFor({ state: "visible", timeout: 5000 }); + await page.waitForTimeout(300); + await findBtn.click({ timeout: 5000, force: attempt >= 2 }); + clicked = true; + } catch (err) { + lastErr = err; + await page.waitForTimeout(600); + } + } + if (!clicked) throw new Error(`Find Flights click failed: ${lastErr?.message}`); + + // Wait up to 25s for the search/simple response. + const searchResponse = await searchResponsePromise; + if (searchResponse && !captured.searchSimple) { + // In rare cases our page.on("response") handler hasn't captured it yet. + try { + const body = await searchResponse.text(); + captured.searchSimple = { + url: searchResponse.url(), + method: "POST", + status: searchResponse.status(), + body, + }; + } catch {} + } + done(captured.searchSimple ? `status=${captured.searchSimple.status}, url=${page.url()}` : `NOT CAPTURED (url=${page.url()})`); + + // ---------- 9. Save artifacts + summarize flight loads ---------- + done = step("Write artifacts"); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const baseName = `${origin}-${destination}-${date}-${stamp}`; + const artifactPaths = {}; + + if (captured.searchSimple) { + const p = join(outDir, `${baseName}.search-simple.json`); + await writeFile(p, captured.searchSimple.body); + artifactPaths.searchSimple = p; + } + if (captured.lowFare) { + const p = join(outDir, `${baseName}.lowfare.json`); + await writeFile(p, captured.lowFare.body); + artifactPaths.lowFare = p; + } + const callsPath = join(outDir, `${baseName}.calls.json`); + await writeFile(callsPath, JSON.stringify(captured.allCalls, null, 2)); + artifactPaths.calls = callsPath; + done(JSON.stringify(artifactPaths)); + + if (captured.searchSimple) { + console.log("\n=== Flight loads ==="); + try { + const parsed = JSON.parse(captured.searchSimple.body); + const loads = extractFlightLoads(parsed); + if (loads.length === 0) { + console.log("(no journeys in response)"); + } else { + for (const load of loads) { + const depTime = load.departTime.replace("T", " ").slice(0, 16); + const arrTime = load.arriveTime.replace("T", " ").slice(0, 16); + const lowest = load.lowestFare != null ? `$${load.lowestFare}` : "—"; + console.log( + ` ${load.flightNumber.padEnd(9)} ${load.departure}→${load.arrival} ` + + `${depTime} → ${arrTime} stops=${load.stops} ` + + `seats=${load.totalAvailable} from=${lowest}` + ); + for (const c of load.classes) { + console.log( + ` class=${c.classOfService} bundle=${c.productClass} ` + + `count=${c.availableCount} fare=$${c.fareTotal ?? "?"} rev=$${c.revenueTotal ?? "?"}` + ); + } + } + } + } catch (err) { + console.log(` (failed to parse search/simple body: ${err.message})`); + } + } else { + console.log("\n!! search/simple response was never captured — check for Akamai block or UI flow break."); + console.log(" All api.jsx.com calls seen:"); + for (const c of captured.allCalls) { + console.log(` ${c.method} ${c.status} ${c.url}`); + } + process.exitCode = 1; + } +} catch (err) { + console.error("\nFATAL:", err.message); + try { + const shot = join(outDir, `crash-${Date.now()}.png`); + await page.screenshot({ path: shot, fullPage: true }); + console.error("screenshot:", shot); + } catch {} + process.exitCode = 1; +} finally { + try { await browser.close(); } catch {} + if (chromeProc && !chromeProc.killed) { + chromeProc.kill("SIGTERM"); + // Give it a moment to exit cleanly before the process exits. + await delay(300); + } +} + +// ---------- helpers ---------- + +async function selectStation(page, index, code) { + // Open the station picker at position `index` (0 = origin, 1 = destination) + // then click the matching option by IATA code. + const result = await page.evaluate(async ({ index, code }) => { + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + + const triggers = Array.from(document.querySelectorAll( + "[aria-label='Station select'], [aria-label*='Station select']" + )).filter(visible); + const trigger = triggers[index]; + if (!trigger) return { ok: false, reason: "trigger-not-found", triggerCount: triggers.length }; + + trigger.click(); + await sleep(800); + + // The dropdown sometimes has a search box — try to type the code first. + const searchInput = Array.from(document.querySelectorAll("input")) + .filter(visible) + .find((i) => (i.getAttribute("placeholder") || "").toLowerCase() === "airport or city"); + if (searchInput) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + searchInput.focus(); + setter ? setter.call(searchInput, code) : (searchInput.value = code); + searchInput.dispatchEvent(new InputEvent("input", { bubbles: true, data: code })); + searchInput.dispatchEvent(new Event("change", { bubbles: true })); + await sleep(500); + } + + const items = Array.from(document.querySelectorAll( + "li[role='option'].station-options__item, li[role='option']" + )).filter(visible); + if (items.length === 0) return { ok: false, reason: "no-dropdown-items" }; + + const pattern = new RegExp(`(^|\\b)${code}(\\b|$)`, "i"); + const match = items.find((el) => pattern.test((el.innerText || el.textContent || "").trim())); + if (!match) { + return { + ok: false, + reason: "code-not-in-list", + code, + sample: items.slice(0, 10).map((i) => i.textContent.trim().slice(0, 60)), + }; + } + + match.scrollIntoView?.({ block: "center" }); + for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { + match.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); + } + match.click?.(); + await sleep(500); + + // Angular API fallback if click did not update the trigger text. + const after = Array.from(document.querySelectorAll("[aria-label='Station select']"))[index]; + const afterText = after?.textContent.trim() || ""; + if (!afterText.includes(code)) { + const ctxKey = Object.keys(match).find((k) => k.startsWith("__ngContext__")); + if (ctxKey) { + const ctx = match[ctxKey]; + for (const item of ctx) { + if (!item || typeof item !== "object") continue; + for (const m of ["_selectViaInteraction", "select", "onSelect", "onClick", "handleClick"]) { + if (typeof item[m] === "function") { + try { item[m](); } catch {} + } + } + } + } + await sleep(400); + } + + const finalText = Array.from(document.querySelectorAll("[aria-label='Station select']"))[index] + ?.textContent.trim() || ""; + return { ok: finalText.includes(code), finalText }; + }, { index, code }); + + if (!result.ok) { + throw new Error(`Station ${code} (index ${index}) failed: ${JSON.stringify(result)}`); + } +} + +// Pull a per-flight load summary out of the Navitaire availability payload. +// +// Real shape (confirmed via live DAL→HOU response): +// data.results[n].trips[n].journeysAvailableByMarket["DAL|HOU"][] → journey +// journey.segments[n].identifier.{carrierCode,identifier} → flight no +// journey.segments[n].designator.{origin,destination,departure,arrival} +// journey.fares[n].fareAvailabilityKey → links to +// data.faresAvailable[key].totals.fareTotal → price +// journey.fares[n].details[n].availableCount → load +// data.faresAvailable[key].fares[n].classOfService → fare class +function extractFlightLoads(payload) { + const loads = []; + const results = payload?.data?.results || []; + const faresAvailable = payload?.data?.faresAvailable || {}; + + for (const result of results) { + const trips = result?.trips || []; + for (const trip of trips) { + const byMarket = trip?.journeysAvailableByMarket || {}; + for (const marketKey of Object.keys(byMarket)) { + const journeys = byMarket[marketKey] || []; + for (const j of journeys) { + // Build flight-number string from each segment so we cover direct + connection. + const segs = j?.segments || []; + const flightNumbers = segs + .map((s) => `${s?.identifier?.carrierCode || ""}${s?.identifier?.identifier || ""}`) + .filter(Boolean) + .join(" + "); + const firstSeg = segs[0]; + const lastSeg = segs[segs.length - 1]; + const departure = j?.designator?.origin || firstSeg?.designator?.origin || "?"; + const arrival = j?.designator?.destination || lastSeg?.designator?.destination || "?"; + const departTime = j?.designator?.departure || firstSeg?.designator?.departure || "?"; + const arriveTime = j?.designator?.arrival || lastSeg?.designator?.arrival || "?"; + + // Each journey.fares entry links via fareAvailabilityKey to a + // faresAvailable record that carries price + class-of-service. + const classLoads = []; + for (const f of j?.fares || []) { + const key = f?.fareAvailabilityKey; + const fareRecord = key ? faresAvailable[key] : null; + const totals = fareRecord?.totals || {}; + const classOfService = fareRecord?.fares?.[0]?.classOfService || "?"; + const productClass = fareRecord?.fares?.[0]?.productClass || "?"; + const availableCount = (f?.details || []) + .reduce((max, d) => Math.max(max, d?.availableCount ?? 0), 0); + classLoads.push({ + fareAvailabilityKey: key, + classOfService, + productClass, + availableCount, + fareTotal: totals.fareTotal ?? null, + revenueTotal: totals.revenueTotal ?? null, + }); + } + + // Total "seats available for sale" at this price point = sum of details. + // Lowest fare = cheapest fareTotal across the fare buckets. + const totalAvailable = classLoads.reduce((sum, c) => sum + (c.availableCount || 0), 0); + const lowestFare = classLoads + .map((c) => c.fareTotal) + .filter((p) => p != null) + .reduce((min, p) => (min == null || p < min ? p : min), null); + + loads.push({ + market: marketKey, + flightNumber: flightNumbers || "?", + departure, + arrival, + departTime, + arriveTime, + stops: j?.stops ?? 0, + totalAvailable, + lowestFare, + classes: classLoads, + }); + } + } + } + } + return loads; +}