Add JSX reverse-engineering notes
Capture the findings from the JSXWebViewFetcher rewrite session: - The Navitaire /api/nsk/v4/availability/search/simple endpoint that actually contains the flight load data, including the request body shape and the nested response structure (journeysAvailableByMarket → fares joined with faresAvailable for price/class). - How the anonymous auth token lands in sessionStorage (navitaire.digital.token) and how to use it as the Authorization header on a direct fetch() from inside the page context. - The jsx.com SPA one-way form structure (trip-type mat-select, station pickers, custom two-month range picker with DONE button, Find Flights submit), and the selectors / strategies that work for each one. - The Osano cookie banner gotcha — its role="dialog" fools calendar detection, so the banner node must be force-removed after accepting. - The datepicker quirks: JSX uses a custom picker (not mat-calendar), renders in two phases (shell then cells), and day cells carry aria-labels in the format "Saturday, April 11, 2026" with a weekday prefix — so exact-match "April 11, 2026" fails but loose month+year+day-word-boundary matching works. - The central finding: WKWebView's synthetic DOM events have isTrusted=false, so JSX's datepicker never commits its day-cell selection into the Angular FormControl, Angular's search() sees the form as invalid, and no POST fires. Playwright doesn't hit this because CDP's Input.dispatchMouseEvent produces trusted events. - The Akamai surface: external HTTP clients are blocked, Playwright's own launch() is blocked on POST /search/simple (ERR_HTTP2_PROTOCOL _ERROR), connectOverCDP to a plain Chrome works, and WKWebView's same-origin fetch() from inside a loaded jsx.com page works. - The working approach (direct POST from page context using the sessionStorage token) and why it sidesteps both the trusted-events and the Akamai problems. - The network interceptor pattern that distinguishes "Angular never fired the POST" from "Angular fired it but the network rejected it" — critical for diagnosing the trusted-events trap. - Code pointers to the Swift runtime (JSXWebViewFetcher.swift), the iOS call site (AirlineLoadService.fetchJSXLoad), and the Playwright reference (scripts/jsx_playwright_search.mjs). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
444
JSX_NOTES.md
Normal file
444
JSX_NOTES.md
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
# JSX / JetSuiteX — Reverse Engineering Notes
|
||||||
|
|
||||||
|
How to extract per-flight loads from `jsx.com` from an iOS app (WKWebView) or
|
||||||
|
a command-line harness (Playwright). Findings from debugging this on real
|
||||||
|
hardware against the live production site in April 2026.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
1. JSX's website runs on Navitaire. The data you want (seats available per
|
||||||
|
fare class, prices per class, all flights for the day) lives in the
|
||||||
|
`POST /api/nsk/v4/availability/search/simple` response body.
|
||||||
|
2. You **cannot** call that endpoint from outside a loaded `jsx.com` page —
|
||||||
|
Akamai will reject the request with an HTTP/2 protocol error based on the
|
||||||
|
TLS fingerprint of whatever's making the call.
|
||||||
|
3. You **can** call it from inside a loaded `jsx.com` page, using the
|
||||||
|
browser's own `fetch()` with the anonymous token the SPA stashes in
|
||||||
|
`sessionStorage["navitaire.digital.token"]`. Same-origin + browser TLS +
|
||||||
|
browser cookies means Akamai sees it as a normal user request.
|
||||||
|
4. Driving the on-page UI to submit the search form is a dead end in
|
||||||
|
**WKWebView** (but works in Playwright) because of a subtle trusted-event
|
||||||
|
issue described below.
|
||||||
|
|
||||||
|
The working pattern is therefore:
|
||||||
|
|
||||||
|
Navigate browser → jsx.com (real page load)
|
||||||
|
Wait for SPA bootstrap (station buttons exist, token call finishes)
|
||||||
|
Read token from sessionStorage
|
||||||
|
POST /api/nsk/v4/availability/search/simple ← from inside the page
|
||||||
|
Parse the response
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Base: `https://api.jsx.com`
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/api/nsk/v2/token` | Anonymous auth token. Fired by the SPA automatically on page load. Response body is `{ data: { token: "eyJhbGci...", idleTimeoutInMinutes: 15 } }`. |
|
||||||
|
| `POST` | `/api/v2/graph/primaryResources` | Route network / station list. SPA bootstrap. |
|
||||||
|
| `POST` | `/api/v2/graph/secondaryResources` | Extra static resources. SPA bootstrap. |
|
||||||
|
| `POST` | `/api/v2/graph/setCulture` | Locale setup. SPA bootstrap. |
|
||||||
|
| `GET` | `/api/nsk/v1/resources/contents?Type=GeneralReference` | Reference data. SPA bootstrap. |
|
||||||
|
| `GET` | `/api/nsk/v1/availability/lowfare/estimate?origin=X&destination=Y&...` | Per-day cheapest fare for a route across a date range. Fires when the user selects origin+destination. Useful as a warm-up / liveness check. |
|
||||||
|
| `POST` | `/api/nsk/v4/availability/search/simple` | **The data you want.** Fired when the user clicks Find Flights. Contains every flight for the route/date plus per-fare-class prices and counts. |
|
||||||
|
| `PUT` | `/api/nsk/v2/token` | Token refresh. Happens after a successful search. |
|
||||||
|
|
||||||
|
All endpoints require the browser's Akamai cookies from a real `jsx.com` page load. The `search/simple` endpoint additionally requires the `Authorization` header set to the token (raw, **no** `Bearer ` prefix — just the JWT string).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
The SPA stores its anonymous token in `sessionStorage["navitaire.digital.token"]` as JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"idleTimeoutInMinutes": 15
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Read it from inside the page:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const raw = sessionStorage.getItem("navitaire.digital.token");
|
||||||
|
const token = raw ? (JSON.parse(raw).token || "") : "";
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait up to ~3 seconds after page load for it to appear — the `POST /api/nsk/v2/token` call that populates it is async.
|
||||||
|
|
||||||
|
Use it as the `Authorization` header value on the `search/simple` POST:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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(requestBody)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request body (`/availability/search/simple`)
|
||||||
|
|
||||||
|
Byte-identical to what the JSX website's own `search()` method posts when
|
||||||
|
its form is valid. Confirmed by intercepting the real call in Playwright.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"beginDate": "2026-04-15",
|
||||||
|
"destination": "HOU",
|
||||||
|
"origin": "DAL",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`beginDate` is `YYYY-MM-DD`. No `endDate` needed for one-way. `origin` and `destination` are IATA codes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Response shape (`/availability/search/simple`)
|
||||||
|
|
||||||
|
The response nests per-flight data inside a by-market dict, with fare
|
||||||
|
pricing split into a separate top-level dict that you join via key. Here's
|
||||||
|
the bit that matters:
|
||||||
|
|
||||||
|
```
|
||||||
|
data
|
||||||
|
├── results[]
|
||||||
|
│ └── trips[]
|
||||||
|
│ ├── date: "2026-04-15T00:00:00"
|
||||||
|
│ └── journeysAvailableByMarket
|
||||||
|
│ └── "DAL|HOU": [ ← key is `${origin}|${destination}`
|
||||||
|
│ {
|
||||||
|
│ stops: 0,
|
||||||
|
│ designator: { origin, destination, departure, arrival },
|
||||||
|
│ segments: [
|
||||||
|
│ {
|
||||||
|
│ identifier: { carrierCode: "XE", identifier: "280" },
|
||||||
|
│ designator: { origin, destination, departure, arrival },
|
||||||
|
│ legs: [{
|
||||||
|
│ legInfo: { equipmentType: "ER4", ... }
|
||||||
|
│ }]
|
||||||
|
│ }
|
||||||
|
│ ],
|
||||||
|
│ fares: [
|
||||||
|
│ {
|
||||||
|
│ fareAvailabilityKey: "MH5Rfn5YRX5R...",
|
||||||
|
│ details: [{ availableCount: 1, status: 1, ... }]
|
||||||
|
│ }
|
||||||
|
│ ]
|
||||||
|
│ },
|
||||||
|
│ ... more journeys
|
||||||
|
│ ]
|
||||||
|
└── faresAvailable ← keyed by fareAvailabilityKey
|
||||||
|
└── "MH5Rfn5YRX5R...": {
|
||||||
|
totals: { fareTotal: 339, revenueTotal: 329.2, ... },
|
||||||
|
fares: [
|
||||||
|
{
|
||||||
|
classOfService: "Q",
|
||||||
|
productClass: "HO", ← HO = Hop-On, AI = All-In
|
||||||
|
fareBasisCode: "Q00AHOXA",
|
||||||
|
passengerFares: [{ passengerType: "ADT", fareAmount: 339, ... }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**To build a per-flight load summary**, for each journey in `journeysAvailableByMarket[<market>]`:
|
||||||
|
|
||||||
|
1. Flight number = `segments[0].identifier.carrierCode + segments[0].identifier.identifier` (e.g. `"XE" + "280"` → `"XE280"`). For multi-segment journeys concatenate all segments' identifiers.
|
||||||
|
2. Origin / destination / times come from `journey.designator` (or fall back to `segments[0].designator` / `segments[last].designator`).
|
||||||
|
3. Equipment type is on `segments[0].legs[0].legInfo.equipmentType`.
|
||||||
|
4. For each entry in `journey.fares[]`, look up `fareAvailabilityKey` in `data.faresAvailable`. From that record:
|
||||||
|
- `totals.fareTotal` = price including taxes
|
||||||
|
- `totals.revenueTotal` = base fare
|
||||||
|
- `fares[0].classOfService` = single-letter fare code
|
||||||
|
- `fares[0].productClass` = "HO" (Hop-On) or "AI" (All-In)
|
||||||
|
5. Sum `details[].availableCount` for that fare bucket — that's the seats sellable at that price point.
|
||||||
|
6. Total sellable seats for the flight = sum across all fare buckets on that journey.
|
||||||
|
7. Lowest price = `min` of all `fareTotal` values.
|
||||||
|
|
||||||
|
JSX flies ERJ-145 and Embraer 135 variants with ~30 seats configured 1x2. `availableCount` numbers in the single digits are normal.
|
||||||
|
|
||||||
|
Example parsed output for `DAL → HOU` on a given day:
|
||||||
|
|
||||||
|
```
|
||||||
|
XE280 DAL→HOU 07:15→08:30 stops=0 seats=8 from=$317
|
||||||
|
class=N bundle=HO count=4 fare=$317 rev=$307.2
|
||||||
|
class=N bundle=AI count=4 fare=$479 rev=$469.2
|
||||||
|
XE292 DAL→HOU 10:35→11:50 stops=0 seats=2 from=$409
|
||||||
|
class=W bundle=HO count=1 fare=$409 rev=$399.2
|
||||||
|
class=W bundle=AI count=1 fare=$649 rev=$639.2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The jsx.com SPA UI — what the user does
|
||||||
|
|
||||||
|
This is only relevant if you're **driving the UI** (not doing a direct API
|
||||||
|
call). Useful for smoke testing that the page is functional and the
|
||||||
|
anonymous token is fresh, but see the WKWebView caveats below.
|
||||||
|
|
||||||
|
Structure of the one-way search form at `https://www.jsx.com/home/search`:
|
||||||
|
|
||||||
|
1. **Trip type** — a Material `mat-select` with combobox role. Options: "Round Trip", "One Way", "Multi City". Default is Round Trip.
|
||||||
|
2. **Origin station** — `button[aria-label='Station select']` (index 0). Opens a dropdown with a search input (placeholder "Airport or city") and a list of `li[role='option'].station-options__item` entries.
|
||||||
|
3. **Destination station** — `button[aria-label='Station select']` (index 1). Same structure as origin.
|
||||||
|
4. **Depart date** — `input[aria-label='Depart Date']` opens a custom two-month range picker overlay on click.
|
||||||
|
5. **Return date** — only visible when trip type is Round Trip.
|
||||||
|
6. **Passengers** — defaults to 1 adult, fine to leave alone.
|
||||||
|
7. **Find Flights** button — submits; Angular's `search()` handler POSTs `/availability/search/simple` if the form validates.
|
||||||
|
|
||||||
|
### The custom datepicker
|
||||||
|
|
||||||
|
JSX does **not** use Angular Material's `mat-calendar`. It's a custom
|
||||||
|
component and none of the standard Material selectors match:
|
||||||
|
|
||||||
|
- `mat-calendar`, `.mat-calendar`, `mat-datepicker-content`, `[class*='mat-datepicker']` — all return null.
|
||||||
|
- `.mat-calendar-period-button` — doesn't exist. There's no period button to parse "April 2026" out of.
|
||||||
|
- `.mat-calendar-body-cell` — doesn't exist. Day cells are something else entirely.
|
||||||
|
|
||||||
|
What **does** work:
|
||||||
|
|
||||||
|
- The picker mounts under `.cdk-overlay-container` in a pane that visibly contains month-name text (the Angular CDK overlay system is Material-ish even if the picker isn't).
|
||||||
|
- Day cells carry `aria-label` attributes in the format `"Saturday, April 11, 2026"` — **weekday prefix included**. That's why an exact match against `"April 11, 2026"` fails but a loose contains-month-AND-year-AND-day-word-boundary match succeeds.
|
||||||
|
- Two months are shown side by side (e.g. April + May in April). Clicking a day cell doesn't close the picker.
|
||||||
|
- There's a **"DONE"** button in the bottom right of the picker. It **must** be clicked to commit the selection. Without it, the picker stays open and the depart-date input value isn't set (when the picker is open there are *two* depart-date inputs in the DOM; after DONE there's one).
|
||||||
|
|
||||||
|
### The picker renders in two phases
|
||||||
|
|
||||||
|
First phase ~150–400 ms after click: overlay shell with month-name text (so selectors matching on month-name text succeed). Second phase a few hundred ms later: the actual day cells with aria-labels become queryable.
|
||||||
|
|
||||||
|
If you have separate script evaluations for "open picker" and "click day cell" (i.e. cross-bridge calls in WKWebView), the second call can race the second phase and see no cells. Always poll for at least one `[aria-label]` matching `/\b(January|…|December)\s+\d{1,2},?\s+\d{4}/` before trying to click a specific cell. Polling timeout of ~5 seconds is sufficient.
|
||||||
|
|
||||||
|
### Mat-select `One Way` option
|
||||||
|
|
||||||
|
The trip-type combobox needs a multi-strategy opener. None of these alone is
|
||||||
|
reliable across browser engines:
|
||||||
|
|
||||||
|
1. Plain `.click()` on the combobox
|
||||||
|
2. `focus()` + `keydown` of `Enter`, `Space`, or `ArrowDown`
|
||||||
|
3. Walk `__ngContext__` and call the Angular mat-select instance's `.open()` method
|
||||||
|
|
||||||
|
Once a visible `mat-option` appears, click the "One Way" option with the full
|
||||||
|
event sequence (`pointerdown` → `mousedown` → `pointerup` → `mouseup` →
|
||||||
|
`click` → `element.click()`). If the return-date input is still visible after
|
||||||
|
that, fall back to walking `__ngContext__` on the mat-option for
|
||||||
|
`_selectViaInteraction()` or `select()` and calling it directly.
|
||||||
|
|
||||||
|
### Station picker
|
||||||
|
|
||||||
|
- Click the `button[aria-label='Station select']` at the desired index (0 = origin, 1 = destination).
|
||||||
|
- The dropdown renders `li[role='option'].station-options__item` entries plus a search `input[placeholder='Airport or city']`.
|
||||||
|
- Type the IATA code into the search input via the native value setter + `input`/`change` events, wait ~500 ms for filtering, then click the option whose text contains the code at a word boundary.
|
||||||
|
- Fall back to `__ngContext__._selectViaInteraction()` / `.select()` if the click doesn't update the station button's text.
|
||||||
|
|
||||||
|
### Osano cookie banner
|
||||||
|
|
||||||
|
JSX embeds Osano. The banner is a `div[role="dialog"]` with class
|
||||||
|
`osano-cm-window` / `osano-cm-dialog`. Two problems:
|
||||||
|
|
||||||
|
1. It intercepts clicks on the page behind it.
|
||||||
|
2. Its `role="dialog"` matches any selector that looks for open dialogs, so code trying to find the datepicker via `[role='dialog']` will match Osano instead.
|
||||||
|
|
||||||
|
Fix: after accepting it (class-based: `.osano-cm-accept-all`, `.osano-cm-accept`; or text-based: "Accept", "Accept All", "I Agree", "Got it"), **force-remove the banner node**:
|
||||||
|
|
||||||
|
```js
|
||||||
|
document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach(el => el.remove());
|
||||||
|
```
|
||||||
|
|
||||||
|
### Find Flights button
|
||||||
|
|
||||||
|
Visible on the main form. Angular click handler calls `search()`, which:
|
||||||
|
|
||||||
|
1. Checks `form.invalid`. If true, marks touched and returns without firing a request.
|
||||||
|
2. Otherwise builds the request body (same shape documented above) and POSTs `/availability/search/simple`.
|
||||||
|
|
||||||
|
The button being visually enabled does **not** mean Angular thinks the form
|
||||||
|
is valid — Angular rechecks on every click. If `search()` silently bails, it
|
||||||
|
won't surface any error to the DOM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WKWebView vs Playwright — the trusted-events trap
|
||||||
|
|
||||||
|
This is the single most important finding from this session.
|
||||||
|
|
||||||
|
### The symptom
|
||||||
|
|
||||||
|
In WKWebView, after driving the entire UI (one-way, origin, destination,
|
||||||
|
day cell, DONE button), the depart-date input visually shows `"Sat, Apr 11"`
|
||||||
|
and the Find Flights button is visually enabled. Clicking it fires the click
|
||||||
|
handler (you can prove it with a capture listener), but no POST to
|
||||||
|
`/availability/search/simple` happens — ever. The only network call that
|
||||||
|
fires after the click is the token refresh that was already scheduled.
|
||||||
|
|
||||||
|
### The cause
|
||||||
|
|
||||||
|
WKWebView's synthetic DOM events produced by
|
||||||
|
`element.dispatchEvent(new MouseEvent('click', {...}))` have
|
||||||
|
`event.isTrusted === false`. JSX's custom datepicker only commits its
|
||||||
|
day-cell selection into its Angular `FormControl` on a **trusted** user
|
||||||
|
gesture. So the synthetic click on a day cell visually highlights the cell,
|
||||||
|
lets DONE close the picker, and updates the input's display value — but the
|
||||||
|
underlying FormControl stays `null`.
|
||||||
|
|
||||||
|
When Find Flights is then clicked, the handler runs `search()`, which does:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
search() {
|
||||||
|
if (this.form.invalid) {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.api.post('/availability/search/simple', this.buildBody());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Form is invalid because `departDate` FormControl is `null`, so the method
|
||||||
|
returns early. No POST. Silent failure.
|
||||||
|
|
||||||
|
### Why Playwright doesn't hit this
|
||||||
|
|
||||||
|
Playwright's `page.click()` and `locator.click()` don't produce synthetic
|
||||||
|
DOM events — they go through CDP's `Input.dispatchMouseEvent`, which
|
||||||
|
produces events indistinguishable from real user input, including
|
||||||
|
`isTrusted === true`. JSX's picker accepts those and commits the date.
|
||||||
|
|
||||||
|
### Why Angular's `markAllAsTouched` workaround doesn't help
|
||||||
|
|
||||||
|
You can walk `__ngContext__` on every element looking for `FormControl`
|
||||||
|
instances and call `markAllAsTouched()` + `updateValueAndValidity()`. This
|
||||||
|
does trigger revalidation and reveals all the currently-invalid fields, but
|
||||||
|
it doesn't populate the empty date control — it just marks it dirty. The
|
||||||
|
underlying value is still `null` because nothing wrote to it.
|
||||||
|
|
||||||
|
### The WKWebView workaround
|
||||||
|
|
||||||
|
Don't click Find Flights. Instead, call
|
||||||
|
`/api/nsk/v4/availability/search/simple` directly via `fetch()` from the
|
||||||
|
page context after reading the token out of `sessionStorage`. You still do
|
||||||
|
the UI-driving steps (for page warm-up, session establishment, and as a
|
||||||
|
smoke test that the site is functional), but the actual data fetch happens
|
||||||
|
via a direct API call whose request shape matches exactly what
|
||||||
|
`search()` would have posted.
|
||||||
|
|
||||||
|
This is what `Flights/Services/JSXWebViewFetcher.swift` step 17 does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-bot surface (Akamai)
|
||||||
|
|
||||||
|
JSX fronts `api.jsx.com` with Akamai Bot Manager. Observed behavior:
|
||||||
|
|
||||||
|
| Request source | Result |
|
||||||
|
|---|---|
|
||||||
|
| Plain `curl`, `fetch` from Node, any external HTTP client | Blocked. Almost all endpoints return HTML challenge page or Akamai error. |
|
||||||
|
| Playwright's built-in `chromium.launch()` (both bundled chromium and `channel: "chrome"`) | GET requests succeed, but `POST /availability/search/simple` specifically returns `ERR_HTTP2_PROTOCOL_ERROR`. Playwright injects enough automation bits for Akamai to flag the TLS/H2 fingerprint. |
|
||||||
|
| Real Chrome spawned as a plain process + Playwright attached via `chromium.connectOverCDP()` | **Works reliably.** Chrome has the expected fingerprint and Playwright is only driving it via CDP, not altering it. |
|
||||||
|
| WKWebView on macOS / iOS | GET requests succeed. Direct `POST /availability/search/simple` from inside a loaded `jsx.com` page via `fetch()` also succeeds. The browser session's cookies and TLS fingerprint are trusted. |
|
||||||
|
| WKWebView with UI-driven Find Flights click | Fails — but for an unrelated reason (the trusted-events trap above). Angular never fires the POST in the first place, so Akamai never sees it. |
|
||||||
|
|
||||||
|
Observations:
|
||||||
|
|
||||||
|
- The Akamai block is on the request fingerprint, not on specific URLs. The same binary can reach some endpoints and not others because different endpoints attract different scrutiny.
|
||||||
|
- Cookies set by a real page load are transferable to `fetch()` calls made from the same page context via `credentials: "include"`, and Akamai accepts them.
|
||||||
|
- The `lowfare/estimate` endpoint is a useful liveness probe — it fires automatically when the SPA has origin+destination, and if it succeeds you know the Akamai session is good.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Network interceptor pattern
|
||||||
|
|
||||||
|
If you want to capture JSX traffic programmatically (e.g. in WKWebView or
|
||||||
|
Playwright), override `window.fetch` and `XMLHttpRequest` to tee every
|
||||||
|
request/response to a probe object. Two things to track separately:
|
||||||
|
|
||||||
|
1. **Initiated calls** — every URL passed to `fetch()` or
|
||||||
|
`XMLHttpRequest.send()`, recorded *before* awaiting the response.
|
||||||
|
2. **Completed calls** — every call that returned a response (any status),
|
||||||
|
plus an error marker on network failure.
|
||||||
|
|
||||||
|
This lets you tell apart "Angular's `search()` never fired a POST" (nothing
|
||||||
|
in initiated calls) from "Angular fired a POST but the network rejected it"
|
||||||
|
(initiated but not completed, error flag set). This distinction was critical
|
||||||
|
for diagnosing the WKWebView trusted-events issue — without it, both failure
|
||||||
|
modes look like "no searchSimple response seen".
|
||||||
|
|
||||||
|
See the `window.__jsxProbe` setup in
|
||||||
|
`Flights/Services/JSXWebViewFetcher.swift` step 4 for a working
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-step verification
|
||||||
|
|
||||||
|
Every step in the WKWebView flow has an **action** and one or more
|
||||||
|
**post-condition verifications**. The action runs and returns success
|
||||||
|
or failure; then every verification runs regardless and logs its result.
|
||||||
|
This matters because a step can have an action that returns "ok" but
|
||||||
|
actually left the page in a broken state — e.g. step 12 can return
|
||||||
|
"datepicker open" because it found the overlay shell, while step 13's
|
||||||
|
check reveals the day cells haven't rendered yet. Running all the
|
||||||
|
verifications and surfacing their results per-line in the log (rather
|
||||||
|
than stopping at the first failure) lets you see the full state the
|
||||||
|
failing step produced, not just the first mismatch.
|
||||||
|
|
||||||
|
On failure, the step runner dumps:
|
||||||
|
|
||||||
|
- The action's returned data fields (e.g. `action.sample: [...]`,
|
||||||
|
`action.monthMatches: [...]`) — any diagnostic payload the action JS
|
||||||
|
chose to include gets surfaced automatically.
|
||||||
|
- Page state: current URL, visible `[role='combobox']` count, visible
|
||||||
|
`mat-option` count, Find Flights button disabled/aria-disabled state,
|
||||||
|
recently initiated and completed api.jsx.com calls, and any elements
|
||||||
|
matching `ng-invalid` / `mat-form-field-invalid` / `error` classes so
|
||||||
|
form validation errors are visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code references
|
||||||
|
|
||||||
|
- **iOS runtime flow**: `Flights/Services/JSXWebViewFetcher.swift` — the
|
||||||
|
19-step WKWebView-based flow with per-step verification. Returns
|
||||||
|
`JSXSearchResult { flights: [JSXFlight], rawSearchBody, error }` with one
|
||||||
|
`JSXFlight` entry per unique flight in the search response.
|
||||||
|
|
||||||
|
- **iOS call site**: `Flights/Services/AirlineLoadService.fetchJSXLoad` —
|
||||||
|
invokes the fetcher, logs every returned flight, and picks the
|
||||||
|
caller-requested flight number via digit match.
|
||||||
|
|
||||||
|
- **Playwright reference implementation**: `scripts/jsx_playwright_search.mjs`
|
||||||
|
— standalone command-line harness that drives the real jsx.com SPA from a
|
||||||
|
real Chrome attached via `chromium.connectOverCDP()`. Used as the source
|
||||||
|
of truth for the UI flow and request/response shapes that the Swift
|
||||||
|
rewrite mirrors.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright install chromium # one-time
|
||||||
|
node scripts/jsx_playwright_search.mjs --origin DAL --destination HOU --date 2026-04-16 --headful
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts land in `/tmp/jsx-playwright/` (raw `search-simple.json`, the
|
||||||
|
`lowfare.json` low-fare estimate, and a `calls.json` log of every
|
||||||
|
`api.jsx.com` URL/status the session saw).
|
||||||
Reference in New Issue
Block a user