Now that we've confirmed the direct in-page fetch() POST to /api/nsk/v4/availability/search/simple works end-to-end on real iOS devices (and is the only thing that does — simulator is blocked at the transport layer by Akamai per-endpoint fingerprinting), delete the dead simulator-era attempts that were kept around as hopeful fallbacks: - Delete nativePOSTSearchSimple and all the URLSession+cookie-replay plumbing. URLSession can't reach /search/simple from iOS Simulator either (TLS fingerprint same as WKWebView), and on real device the in-page fetch already works so the URLSession path is never useful. - Delete the ~150 lines of SPA state-harvest JavaScript that walked __ngContext__ to find the parsed availability payload inside Angular services as an attempt-2 fallback. The state-harvest was a proxy for "maybe the POST went through but our interceptor swallowed the response" — that theory is dead now that we know the POST itself is what's blocked in the simulator. - Delete the capturedBody instance property that only nativePOST wrote to. Step 17 is now exactly what it claims to be: read the sessionStorage token, fire a single direct fetch() POST from the page context, return the body on success. ~400 lines removed from JSXWebViewFetcher.swift (2148 -> 1748). Step 18's low-fare fallback stays as graceful degradation when the POST fails (which happens on iOS Simulator). The fallback cabin is now labeled "Route day-total (fallback)" instead of "Route (day total)" so the UI clearly distinguishes a per-flight seat count from a route estimate. JSX_NOTES.md corrected: removed the inaccurate claim that WKWebView POSTs to /search/simple just work. The anti-bot-surface table now separates iOS Simulator (fails) from real iOS device (works) with the specific error modes for each. TL;DR adds a visible caveat at the top that the working path requires a real device; develop with the low-fare fallback in the simulator. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
459 lines
22 KiB
Markdown
459 lines
22 KiB
Markdown
# 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.
|
||
5. **Environment caveat**: the in-page `fetch()` POST (#3) works on a
|
||
**real iOS device** but **fails in the iOS Simulator**. Simulator
|
||
WebKit runs on macOS's CFNetwork stack, whose TLS/H2 fingerprint
|
||
Akamai's per-endpoint protection tier on `/search/simple` treats as
|
||
untrusted. Other `api.jsx.com` endpoints (tokens, bootstrap graph
|
||
calls, low-fare estimate) work from both. **Test on device; develop
|
||
with the low-fare fallback in the simulator.**
|
||
|
||
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 (real device only)
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
**Important environment caveat:** this workaround works on a **real iOS
|
||
device** but **fails in the iOS Simulator**. See the anti-bot surface
|
||
table below for the specific failure modes. Develop with a real device
|
||
plugged in, or accept the low-fare day-total fallback when running
|
||
against the simulator.
|
||
|
||
---
|
||
|
||
## 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 iOS Simulator** | GETs to `api.jsx.com` succeed (`/lowfare/estimate`, `/nsk/v1/token`, `/graph/*`). **`POST /availability/search/simple` fails** with `TypeError: Load failed` (in-page `fetch()`/`XHR`) or `NSURLErrorNetworkConnectionLost` (-1005) (native `URLSession` with cookies forwarded from the WKWebView store). iOS Simulator WebKit runs against macOS's CFNetwork stack, whose TLS/H2 fingerprint Akamai's per-endpoint protection tier treats as untrusted for this specific endpoint. No configuration (custom UA, XHR vs fetch, credentials mode, content type, cookie replay into URLSession) makes it work. |
|
||
| **WKWebView on real iOS hardware** | **Works.** Direct `POST /availability/search/simple` from inside a loaded `jsx.com` page via `fetch()` returns `status=200` with the full per-flight availability payload. Real iOS WebKit has the iOS-specific network stack whose TLS/H2 fingerprint Akamai accepts. The fetcher in `Flights/Services/JSXWebViewFetcher.swift` step 17 does exactly this and produces correct per-class seat counts. |
|
||
| WKWebView with UI-driven Find Flights click (any platform) | Fails for an unrelated reason: WKWebView's synthetic `MouseEvent` has `isTrusted === false`, and JSX's custom datepicker only commits its day-cell selection to the Angular form model on a trusted user gesture. Result: the input visually shows the selected date but the underlying model stays null, `form.invalid === true`, and Angular's `search()` silently returns without firing a request. The direct-fetch POST bypasses this entirely. |
|
||
|
||
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).
|