import { spawn } from "node:child_process"; import { mkdir, rm, writeFile } from "node:fs/promises"; import process from "node:process"; import { setTimeout as delay } from "node:timers/promises"; const chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const port = Number(process.env.CDP_PORT || 9229); const origin = process.env.JSX_ORIGIN || "DAL"; const destination = process.env.JSX_DESTINATION || "HOU"; const beginDate = process.env.JSX_DATE || "2026-04-11"; const headless = process.env.HEADLESS === "1"; const userDataDir = process.env.CHROME_USER_DATA_DIR || `/tmp/jsx-chrome-${Date.now()}`; const artifactsDir = process.env.JSX_ARTIFACTS_DIR || "/tmp/jsx-probe"; const navigationUrl = process.env.JSX_URL || "https://www.jsx.com/"; const settleMs = Number(process.env.SETTLE_MS || 8000); const requestTimeoutMs = Number(process.env.REQUEST_TIMEOUT_MS || 30000); await mkdir(artifactsDir, { recursive: true }); function log(message, details) { if (details === undefined) { console.log(`[probe] ${message}`); return; } console.log(`[probe] ${message}`, details); } async function fetchJson(url) { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } return response.json(); } async function waitForDebugger() { const deadline = Date.now() + requestTimeoutMs; while (Date.now() < deadline) { try { return await fetchJson(`http://127.0.0.1:${port}/json/version`); } catch { await delay(250); } } throw new Error("Timed out waiting for Chrome remote debugger"); } function launchChrome() { const args = [ `--remote-debugging-port=${port}`, `--user-data-dir=${userDataDir}`, "--no-first-run", "--no-default-browser-check", "--disable-default-apps", "--disable-popup-blocking", "--disable-background-networking", "--disable-renderer-backgrounding", "--window-size=1440,1200", navigationUrl, ]; if (headless) { args.splice(args.length - 1, 0, "--headless=new"); } log("Launching Chrome", { chromePath, port, userDataDir, headless, navigationUrl }); const child = spawn(chromePath, args, { detached: false, stdio: ["ignore", "pipe", "pipe"], }); child.stdout.on("data", chunk => { const text = chunk.toString().trim(); if (text) { console.log(`[chrome] ${text}`); } }); child.stderr.on("data", chunk => { const text = chunk.toString().trim(); if (text) { console.error(`[chrome] ${text}`); } }); return child; } class CDPClient { constructor(socketUrl) { this.socketUrl = socketUrl; this.ws = new WebSocket(socketUrl); this.nextId = 1; this.pending = new Map(); this.eventHandlers = new Map(); this.openPromise = new Promise((resolve, reject) => { this.ws.addEventListener("open", () => resolve()); this.ws.addEventListener("error", event => reject(event.error || new Error("WebSocket open failed"))); }); this.ws.addEventListener("message", event => { const message = JSON.parse(event.data.toString()); if (message.id) { const pending = this.pending.get(message.id); if (!pending) { return; } this.pending.delete(message.id); if (message.error) { pending.reject(new Error(message.error.message || JSON.stringify(message.error))); } else { pending.resolve(message.result); } return; } const handlers = this.eventHandlers.get(message.method) || []; for (const handler of handlers) { handler(message.params || {}); } }); } async ready() { await this.openPromise; } async send(method, params = {}) { await this.ready(); const id = this.nextId++; const payload = JSON.stringify({ id, method, params }); const promise = new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`Timed out waiting for ${method}`)); } }, requestTimeoutMs); }); this.ws.send(payload); return promise; } on(method, handler) { const handlers = this.eventHandlers.get(method) || []; handlers.push(handler); this.eventHandlers.set(method, handlers); } async close() { if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { this.ws.close(); } } } async function clickPoint(cdp, point) { await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y, button: "none", }); await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", clickCount: 1, }); await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", clickCount: 1, }); } async function clickButtonByText(cdp, textPattern) { const result = await cdp.send("Runtime.evaluate", { expression: ` (() => { const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); const textOf = element => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim(); const button = Array.from(document.querySelectorAll("button, [role='button']")) .filter(visible) .find(element => ${textPattern}.test(textOf(element))); if (!button) { return { success: false, reason: "button-not-found", pattern: "${textPattern}" }; } const rect = button.getBoundingClientRect(); return { success: true, text: textOf(button), x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; })() `, awaitPromise: true, returnByValue: true, }); const details = result.result?.value; if (!details?.success) { return details; } await clickPoint(cdp, details); await delay(500); return { success: true, text: details.text }; } async function newTarget() { const response = await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT", }); if (!response.ok) { throw new Error(`Failed to create target: HTTP ${response.status}`); } return response.json(); } function visibleFilter(element) { return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); } const pageProbeScript = ` (() => { const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); const textOf = element => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim(); const describe = element => { const label = element.labels && element.labels.length ? Array.from(element.labels).map(label => textOf(label)).join(" | ") : ""; return { tag: element.tagName.toLowerCase(), type: element.getAttribute("type") || "", id: element.id || "", name: element.getAttribute("name") || "", role: element.getAttribute("role") || "", testId: element.getAttribute("data-testid") || "", placeholder: element.getAttribute("placeholder") || "", ariaLabel: element.getAttribute("aria-label") || "", value: element.value || "", text: textOf(element).slice(0, 120), label: label.slice(0, 120), }; }; const elements = Array.from(document.querySelectorAll("input, button, select, textarea, [role='button'], [role='combobox'], [data-testid]")) .filter(visible) .map(describe) .slice(0, 250); return { url: location.href, title: document.title, readyState: document.readyState, bodyTextPreview: textOf(document.body).slice(0, 1000), localStorage: Object.fromEntries(Object.keys(localStorage).sort().map(key => [key, localStorage.getItem(key)])), sessionStorage: Object.fromEntries(Object.keys(sessionStorage).sort().map(key => [key, sessionStorage.getItem(key)])), elements, }; })() `; const acceptConsentScript = ` (() => { const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); const exactLabels = new Set([ "accept", "accept all", "allow all", "i agree", "agree" ]); const candidates = Array.from(document.querySelectorAll("button, [role='button']")) .filter(visible); for (const candidate of candidates) { const text = (candidate.innerText || candidate.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase(); if (exactLabels.has(text)) { candidate.click(); return { clicked: true, text }; } } return { clicked: false }; })() `; const setOneWayTripScript = ` (async () => { const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); const textOf = element => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim(); const tripTrigger = Array.from(document.querySelectorAll("[role='combobox']")) .find(element => /round trip|one way|multi city/i.test(textOf(element))); if (!tripTrigger) { return { success: false, reason: "trip-trigger-not-found" }; } const anyOption = () => Array.from(document.querySelectorAll("mat-option, [role='option']")).find(visible); const tried = []; tripTrigger.scrollIntoView?.({ block: "center" }); tripTrigger.click(); tried.push("click"); await new Promise(resolve => setTimeout(resolve, 500)); if (!anyOption()) { tripTrigger.focus?.(); await new Promise(resolve => setTimeout(resolve, 100)); for (const key of ["Enter", " ", "ArrowDown"]) { tripTrigger.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 new Promise(resolve => setTimeout(resolve, 350)); if (anyOption()) break; } } if (!anyOption()) { const ctxKey = Object.keys(tripTrigger).find(key => key.startsWith("__ngContext__")); if (ctxKey) { for (const item of tripTrigger[ctxKey] || []) { if (item && typeof item === "object" && typeof item.open === "function") { try { item.open(); tried.push("ngContext.open"); } catch {} await new Promise(resolve => setTimeout(resolve, 400)); if (anyOption()) break; } } } } const options = Array.from(document.querySelectorAll("mat-option, [role='option']")).filter(visible); const oneWay = options.find(element => /^one way$/i.test(textOf(element))); if (!oneWay) { return { success: false, reason: "one-way-option-not-found", tried, visibleOptions: options.map(textOf).filter(Boolean).slice(0, 40) }; } oneWay.scrollIntoView?.({ block: "center" }); for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { oneWay.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); } oneWay.click?.(); await new Promise(resolve => setTimeout(resolve, 500)); let usedAngular = false; const returnVisible = Array.from(document.querySelectorAll("input")).some(input => { const label = (input.getAttribute("aria-label") || "").toLowerCase(); return label.includes("return date") && (input.offsetWidth || input.offsetHeight); }); if (returnVisible) { const ctxKey = Object.keys(oneWay).find(key => key.startsWith("__ngContext__")); if (ctxKey) { for (const item of oneWay[ctxKey] || []) { if (!item || typeof item !== "object") continue; 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 new Promise(resolve => setTimeout(resolve, 500)); } const finalReturnVisible = Array.from(document.querySelectorAll("input")).some(input => { const label = (input.getAttribute("aria-label") || "").toLowerCase(); return label.includes("return date") && (input.offsetWidth || input.offsetHeight); }); return { success: !finalReturnVisible, selected: textOf(oneWay), tried, usedAngular, finalReturnVisible }; })() `; const dismissMarketingPopupScript = ` (() => { const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); const closeButton = Array.from(document.querySelectorAll("button, [role='button']")) .filter(visible) .find(element => (element.getAttribute("aria-label") || "").toLowerCase() === "close dialog"); if (!closeButton) { return { dismissed: false }; } closeButton.click(); return { dismissed: true }; })() `; const selectStationScript = (index, stationCode) => ` (async () => { const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); const textOf = element => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim(); const metaOf = element => [ element.getAttribute("placeholder") || "", element.getAttribute("aria-label") || "", element.getAttribute("name") || "", element.id || "", textOf(element.parentElement || element), ].join(" ").toLowerCase(); const nativeValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; const setInputValue = (input, value) => { input.focus(); if (nativeValueSetter) { nativeValueSetter.call(input, value); } else { input.value = value; } input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value })); input.dispatchEvent(new Event("change", { bubbles: true })); }; const stationButtons = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']")) .filter(visible); const trigger = stationButtons[${index}]; if (!trigger) { return { success: false, reason: "station-trigger-not-found", triggers: stationButtons.map(textOf) }; } trigger.click(); await new Promise(resolve => setTimeout(resolve, 700)); const searchInput = Array.from(document.querySelectorAll("input")) .filter(visible) .find(input => { const placeholder = (input.getAttribute("placeholder") || "").toLowerCase(); return placeholder === "airport or city" || (/airport|station|city|search|origin|destination/.test(metaOf(input)) && !/date|email|phone/.test(metaOf(input))); }); if (searchInput) { setInputValue(searchInput, "${stationCode}"); await new Promise(resolve => setTimeout(resolve, 700)); } const stationPattern = new RegExp("(^|\\\\b)${stationCode}(\\\\b|$)", "i"); const candidates = Array.from(document.querySelectorAll("li[role='option'].station-options__item, li[role='option'], .station-options__item")) .filter(visible); const target = candidates.find(element => stationPattern.test(textOf(element))); if (!target) { return { success: false, reason: "station-option-not-found", searchInputMeta: searchInput ? metaOf(searchInput) : null, visibleOptions: candidates.map(textOf).filter(Boolean).slice(0, 80) }; } target.scrollIntoView?.({ block: "center" }); for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); } target.click?.(); await new Promise(resolve => setTimeout(resolve, 500)); const after = () => Array.from(document.querySelectorAll("[aria-label='Station select']")).filter(visible)[${index}]; let finalText = (after()?.textContent || "").trim(); let usedAngular = false; if (!finalText.includes("${stationCode}")) { const ctxKey = Object.keys(target).find(key => key.startsWith("__ngContext__")); if (ctxKey) { for (const item of target[ctxKey] || []) { if (!item || typeof item !== "object") continue; for (const method of ["_selectViaInteraction", "select", "onSelect", "onClick", "handleClick"]) { if (typeof item[method] === "function") { try { item[method](); usedAngular = true; break; } catch {} } } if (usedAngular) break; } } await new Promise(resolve => setTimeout(resolve, 400)); finalText = (after()?.textContent || "").trim(); } return { success: finalText.includes("${stationCode}"), selected: textOf(target), finalText, usedAngular }; })() `; const setDepartDateScript = isoDate => ` (async () => { const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const [year, month, day] = "${isoDate}".split("-").map(Number); const monthNames = [ "January","February","March","April","May","June", "July","August","September","October","November","December" ]; const targetMonthName = monthNames[month - 1]; const input = Array.from(document.querySelectorAll("input")).find(element => { const label = (element.getAttribute("aria-label") || "").toLowerCase(); return label.includes("depart date") || (label.includes("date") && !label.includes("return")); }); if (!input) { return { success: false, reason: "depart-input-not-found" }; } const wrapper = input.closest("mat-form-field, jsx-form-field, .form-field, .jsx-form-field"); const 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); 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) { const panes = overlay.querySelectorAll(".cdk-overlay-pane, [class*='datepicker'], [class*='calendar'], [class*='date-picker']"); for (const pane of panes) { if (!(pane.offsetWidth || pane.offsetHeight)) continue; const text = pane.textContent || ""; if (/january|february|march|april|may|june|july|august|september|october|november|december/i.test(text)) { return pane; } } } const candidates = Array.from(document.querySelectorAll("div, section, article")) .filter(element => element.offsetWidth && element.offsetHeight && element.offsetWidth < 1000) .filter(element => /\\b(Sun|Mon|Tue|Wed|Thu|Fri|Sat)\\b/.test(element.textContent || "")) .filter(element => /january|february|march|april|may|june|july|august|september|october|november|december/i.test(element.textContent || "")); return candidates[0] || null; }; let calendar = findCalendar(); if (!calendar) { input.click(); input.focus?.(); await sleep(500); calendar = findCalendar(); } if (!calendar) { return { success: false, reason: "datepicker-did-not-open" }; } 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(year))) break; const match = text.match(/(\\w+)\\s+(\\d{4})/); let forward = true; if (match) { const current = parseInt(match[2], 10) * 12 + monthIndex(match[1]); const target = year * 12 + (month - 1); forward = current < target; } const button = forward ? next : prev; if (!button || button.disabled) break; button.click(); await sleep(250); } const ariaExact = targetMonthName + " " + day + ", " + year; const ariaLoose = targetMonthName + " " + day + " " + year; const allCells = Array.from(calendar.querySelectorAll("[aria-label]")) .filter(element => element.offsetWidth || element.offsetHeight); let cell = allCells.find(element => { const aria = (element.getAttribute("aria-label") || "").trim(); return aria === ariaExact || aria === ariaLoose || aria.startsWith(ariaExact); }); if (!cell) { const dayRe = new RegExp("\\\\b" + day + "\\\\b"); cell = allCells.find(element => { const aria = element.getAttribute("aria-label") || ""; return aria.includes(targetMonthName) && aria.includes(String(year)) && dayRe.test(aria); }); } if (!cell) { return { success: false, reason: "day-cell-not-found", ariaExact }; } 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); const doneButton = Array.from(document.querySelectorAll("button, [role='button']")) .filter(element => element.offsetWidth || element.offsetHeight) .find(element => /^\\s*done\\s*$/i.test((element.innerText || element.textContent || "").trim())); let doneClicked = false; if (doneButton) { doneButton.scrollIntoView?.({ block: "center" }); for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) { doneButton.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); } doneButton.click?.(); doneClicked = true; await sleep(600); } document.body.click(); for (const element of document.querySelectorAll("input")) { element.dispatchEvent(new Event("blur", { bubbles: true })); } await sleep(300); return { success: true, value: input.value, doneClicked }; })() `; const forceAngularFormUpdateScript = ` (async () => { const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); document.body.click(); for (const input of document.querySelectorAll("input")) { input.dispatchEvent(new Event("blur", { bubbles: true })); } await sleep(300); let touched = 0; for (const element of document.querySelectorAll("*")) { const ctxKey = Object.keys(element).find(key => key.startsWith("__ngContext__")); if (!ctxKey) continue; for (const item of element[ctxKey] || []) { if (!item || typeof item !== "object" || !(item.controls || item.control)) continue; const form = item.controls ? item : item.control?.controls ? item.control : null; if (!form) continue; try { form.markAllAsTouched?.(); } catch {} try { form.updateValueAndValidity?.(); } catch {} touched++; } if (touched > 20) break; } const findButton = Array.from(document.querySelectorAll("button")) .find(button => /find flights/i.test(button.textContent || "")); return { success: true, touched, findButtonDisabled: Boolean(findButton?.disabled || findButton?.getAttribute("aria-disabled") === "true") }; })() `; const componentSnapshotScript = ` (() => { const safe = (value, depth = 0) => { if (depth > 3) return null; if (value === null || value === undefined) return value; if (typeof value === "function") return "[function]"; if (value instanceof Date) return value.toISOString(); if (typeof value !== "object") return value; if (Array.isArray(value)) return value.slice(0, 10).map(item => safe(item, depth + 1)); const output = {}; for (const key of Object.keys(value).slice(0, 25)) { try { output[key] = safe(value[key], depth + 1); } catch { output[key] = "[unreadable]"; } } return output; }; for (const element of document.querySelectorAll("*")) { const ctxKey = Object.keys(element).find(key => key.startsWith("__ngContext__")); if (!ctxKey) continue; const ctx = element[ctxKey]; if (!Array.isArray(ctx)) continue; for (let idx = 0; idx < ctx.length; idx++) { const item = ctx[idx]; if (!item || typeof item !== "object") continue; if (!("beginDate" in item) || !("origin" in item) || !("destination" in item) || typeof item.search !== "function") { continue; } const keys = Object.keys(item).sort(); const proto = Object.getPrototypeOf(item); const protoMethods = proto ? Object.getOwnPropertyNames(proto) .filter(name => name !== "constructor" && typeof item[name] === "function") .sort() : []; return { found: true, idx, hostTag: (element.tagName || "").toLowerCase(), hostClass: String(element.className || ""), keys, interestingKeys: keys.filter(name => /(origin|destination|home|station|airport|date|trip|search|market|store)/i.test(name)), protoMethods: protoMethods.filter(name => /(origin|destination|home|station|airport|date|trip|search|select|set|update|submit)/i.test(name)), gatingValues: { departureSelectionComplete: item.departureSelectionComplete, arrivalSelectionComplete: item.arrivalSelectionComplete, tripType: item.tripType, searchStart: item.searchStart, searchComplete: item.searchComplete, searchButtonDisabled: Boolean(item.searchButton?.disabled), searchButtonAriaDisabled: item.searchButton?.getAttribute?.("aria-disabled") || null }, methodBodies: Object.fromEntries( ["fromUpdated", "toUpdated", "setSelectedStations", "validateTripSelection", "validateDates", "search"] .filter(name => typeof item[name] === "function") .map(name => [name, String(item[name]).slice(0, 1200)]) ), fromStationProtoMethods: item.fromStation ? Object.getOwnPropertyNames(Object.getPrototypeOf(item.fromStation) || {}) .filter(name => name !== "constructor" && typeof item.fromStation[name] === "function") .filter(name => /(select|choose|station|input|search|update|focus|click|set)/i.test(name)) .sort() : [], toStationProtoMethods: item.toStation ? Object.getOwnPropertyNames(Object.getPrototypeOf(item.toStation) || {}) .filter(name => name !== "constructor" && typeof item.toStation[name] === "function") .filter(name => /(select|choose|station|input|search|update|focus|click|set)/i.test(name)) .sort() : [], originPreview: safe(item.origin), destinationPreview: safe(item.destination), beginDatePreview: safe(item.beginDate), interestingValues: Object.fromEntries( keys .filter(name => /(origin|destination|home|station|airport|date|trip|market|arrivalSelectionComplete|departureSelectionComplete)/i.test(name)) .slice(0, 20) .map(name => { try { return [name, safe(item[name])]; } catch { return [name, "[unreadable]"]; } }) ) }; } } return { found: false }; })() `; const stationOverlayDebugScript = index => ` (async () => { const visible = element => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); const textOf = element => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim(); const stationButtons = Array.from(document.querySelectorAll("[aria-label='Station select'], [aria-label*='Station select']")) .filter(visible); const trigger = stationButtons[${index}]; if (!trigger) { return { success: false, reason: "station-trigger-not-found" }; } trigger.click(); await new Promise(resolve => setTimeout(resolve, 900)); const elements = Array.from(document.querySelectorAll("input, button, [role='button'], [role='option'], [role='dialog'], mat-option, li, .mat-mdc-option, .mat-option")) .filter(visible) .map(element => ({ tag: element.tagName.toLowerCase(), role: element.getAttribute("role") || "", ariaLabel: element.getAttribute("aria-label") || "", placeholder: element.getAttribute("placeholder") || "", id: element.id || "", className: element.className || "", text: textOf(element).slice(0, 160) })) .filter(item => item.text || item.ariaLabel || item.placeholder) .slice(0, 150); return { success: true, triggerText: textOf(trigger), activeElement: document.activeElement ? { tag: document.activeElement.tagName.toLowerCase(), ariaLabel: document.activeElement.getAttribute("aria-label") || "", placeholder: document.activeElement.getAttribute("placeholder") || "", id: document.activeElement.id || "", text: textOf(document.activeElement).slice(0, 160) } : null, elements }; })() `; const availabilityProbeScript = ` (async ({ origin, destination, beginDate }) => { const payload = { beginDate, destination, 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 }; const tokenData = JSON.parse(sessionStorage.getItem("navitaire.digital.token") || "null"); const token = tokenData?.token || ""; const response = 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(payload) }); const body = await response.text(); return { tokenPresent: Boolean(token), tokenPrefix: token.slice(0, 24), tokenExpiration: tokenData?.expiration || null, status: response.status, bodyLength: body.length, bodySnippet: body.slice(0, 4000) }; })(${JSON.stringify({ origin, destination, beginDate })}) `; async function main() { const chrome = launchChrome(); let cdp; try { await waitForDebugger(); const target = await newTarget(); log("Connected target", { id: target.id, url: target.url }); cdp = new CDPClient(target.webSocketDebuggerUrl); await cdp.ready(); const requests = []; const responseMetaByRequestId = new Map(); cdp.on("Network.requestWillBeSent", params => { const url = params.request?.url || ""; if (!url.includes("jsx.com")) { return; } const record = { stage: "request", type: params.type || "", method: params.request?.method || "", url, postData: params.request?.postData || "", hasAuthHeader: Boolean(params.request?.headers?.Authorization), }; requests.push(record); console.log(JSON.stringify(record)); }); cdp.on("Network.responseReceived", params => { const url = params.response?.url || ""; if (!url.includes("jsx.com")) { return; } const record = { stage: "response", type: params.type || "", status: params.response?.status || 0, mimeType: params.response?.mimeType || "", url, }; responseMetaByRequestId.set(params.requestId, record); requests.push(record); console.log(JSON.stringify(record)); }); cdp.on("Network.loadingFinished", async params => { const meta = responseMetaByRequestId.get(params.requestId); if (!meta || !meta.url.includes("api.jsx.com")) { return; } try { const body = await cdp.send("Network.getResponseBody", { requestId: params.requestId }); const snippet = (body.body || "").slice(0, 3000); const record = { stage: "body", status: meta.status, url: meta.url, bodySnippet: snippet, base64Encoded: Boolean(body.base64Encoded), }; requests.push(record); console.log(JSON.stringify(record)); } catch (error) { console.log(JSON.stringify({ stage: "body-error", url: meta.url, error: error.message, })); } }); await cdp.send("Page.enable"); await cdp.send("Runtime.enable"); await cdp.send("Network.enable", { maxTotalBufferSize: 100_000_000, maxResourceBufferSize: 10_000_000, maxPostDataSize: 1_000_000, }); await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); log("Navigating page target", navigationUrl); await cdp.send("Page.navigate", { url: navigationUrl }); await delay(settleMs); const consentResult = await cdp.send("Runtime.evaluate", { expression: acceptConsentScript, awaitPromise: true, returnByValue: true, }); log("Consent result", consentResult.result?.value); await delay(1500); const initialProbe = await cdp.send("Runtime.evaluate", { expression: pageProbeScript, awaitPromise: true, returnByValue: true, }); await writeFile(`${artifactsDir}/page-probe-before-search.json`, JSON.stringify(initialProbe.result?.value || {}, null, 2)); log("Page probe saved", `${artifactsDir}/page-probe-before-search.json`); if (process.env.DEBUG_STATION_OVERLAY === "1") { const overlayProbe = await cdp.send("Runtime.evaluate", { expression: stationOverlayDebugScript(0), awaitPromise: true, returnByValue: true, }); await writeFile( `${artifactsDir}/station-overlay-debug.json`, JSON.stringify(overlayProbe.result?.value || {}, null, 2) ); log("Station overlay debug", overlayProbe.result?.value); } const popupDismissResult = await cdp.send("Runtime.evaluate", { expression: dismissMarketingPopupScript, awaitPromise: true, returnByValue: true, }); log("Marketing popup result", popupDismissResult.result?.value); const tripTypeResult = await cdp.send("Runtime.evaluate", { expression: setOneWayTripScript, awaitPromise: true, returnByValue: true, }); log("Trip type result", tripTypeResult.result?.value); const originResult = await cdp.send("Runtime.evaluate", { expression: selectStationScript(0, origin), awaitPromise: true, returnByValue: true, }); log("Origin selection result", originResult.result?.value); const destinationResult = await cdp.send("Runtime.evaluate", { expression: selectStationScript(1, destination), awaitPromise: true, returnByValue: true, }); log("Destination selection result", destinationResult.result?.value); const dateResult = await cdp.send("Runtime.evaluate", { expression: setDepartDateScript(beginDate), awaitPromise: true, returnByValue: true, }); log("Depart date result", dateResult); const formUpdateResult = await cdp.send("Runtime.evaluate", { expression: forceAngularFormUpdateScript, awaitPromise: true, returnByValue: true, }); log("Form update result", formUpdateResult.result?.value); const componentSnapshot = await cdp.send("Runtime.evaluate", { expression: componentSnapshotScript, awaitPromise: true, returnByValue: true, }); await writeFile( `${artifactsDir}/component-snapshot-before-search.json`, JSON.stringify(componentSnapshot.result?.value || {}, null, 2) ); log("Component snapshot", componentSnapshot.result?.value); const searchResult = await clickButtonByText(cdp, "/^find flights$/i"); log("Search click result", searchResult); if (process.env.DIRECT_AVAILABILITY_PROBE === "1") { const availabilityProbe = await cdp.send("Runtime.evaluate", { expression: availabilityProbeScript, awaitPromise: true, returnByValue: true, }); await writeFile( `${artifactsDir}/availability-probe.json`, JSON.stringify(availabilityProbe.result?.value || {}, null, 2) ); log("Availability probe", availabilityProbe.result?.value); } await delay(settleMs); const finalProbe = await cdp.send("Runtime.evaluate", { expression: pageProbeScript, awaitPromise: true, returnByValue: true, }); await writeFile(`${artifactsDir}/page-probe-after-search.json`, JSON.stringify(finalProbe.result?.value || {}, null, 2)); await writeFile(`${artifactsDir}/network-events.json`, JSON.stringify(requests, null, 2)); log("Artifacts saved", artifactsDir); } finally { if (cdp) { await cdp.close(); } chrome.kill("SIGTERM"); await delay(500); await rm(userDataDir, { recursive: true, force: true }); } } await main();