From 6e947f2aa98477f81f6a7afb125ef5fc5b697cab Mon Sep 17 00:00:00 2001 From: gnezim Date: Fri, 22 May 2026 11:13:16 +0300 Subject: [PATCH] Fix Aeroflot shell navigation links --- modern.config.ts | 38 ++++++++++++ scripts/aeroflot-url-rewrite.mjs | 8 +++ scripts/dev-server.mjs | 96 ++++++++++++++++++++++++++---- scripts/standalone-server.mjs | 6 +- tests/e2e/standalone-shell.spec.ts | 77 +++++++++++++++++++++++- 5 files changed, 208 insertions(+), 17 deletions(-) create mode 100644 scripts/aeroflot-url-rewrite.mjs diff --git a/modern.config.ts b/modern.config.ts index 1f409e50..5efd37c5 100644 --- a/modern.config.ts +++ b/modern.config.ts @@ -59,6 +59,32 @@ console.error=function(){ if(window.__AFL_SHELL_SHOULD_SUPPRESS__(arguments)){return;} return window.__AFL_SHELL_CONSOLE_ERROR__.apply(console,arguments); };`; +const AEROFLOT_RUNTIME_CONFIG_SCRIPT = ` +window["afl-frontend-runtime-config"]=window["afl-frontend-runtime-config"]||Object.create(null); +window["afl-frontend-runtime-config"].FRONTEND_PROXY=window.location.origin; +window["afl-frontend-runtime-config"].FRONTEND_BACKEND="https://www.aeroflot.ru";`; +const FIX_AEROFLOT_SHELL_LINKS_SCRIPT = ` +window.__AFL_SHELL_REWRITE_LINKS__=function(){ + var anchors=document.querySelectorAll("afl-component.header a[href],afl-component.footer a[href]"); + Array.prototype.forEach.call(anchors,function(anchor){ + var href=anchor.getAttribute("href")||""; + if(href.indexOf("/personal/login")===0||href.indexOf("/pkl/app/")===0){ + anchor.setAttribute("href","https://www.aeroflot.ru"+href); + } + }); +}; +window.__AFL_SHELL_LINK_OBSERVER__=new MutationObserver(function(){ + window.__AFL_SHELL_REWRITE_LINKS__(); +}); +window.__AFL_SHELL_LINK_OBSERVER__.observe(document.documentElement,{ + childList:true, + subtree:true, + attributes:true, + attributeFilter:["href"] +}); +window.addEventListener("DOMContentLoaded",function(){ + window.__AFL_SHELL_REWRITE_LINKS__(); +});`; const modernCommand = process.argv.join(" "); const npmLifecycleEvent = process.env["npm_lifecycle_event"] ?? ""; @@ -94,6 +120,18 @@ const standaloneShellTags = isRemote append: true, children: SUPPRESS_AEROFLOT_LOADER_WARNINGS_SCRIPT, }, + { + tag: "script", + head: true, + append: true, + children: AEROFLOT_RUNTIME_CONFIG_SCRIPT, + }, + { + tag: "script", + head: true, + append: true, + children: FIX_AEROFLOT_SHELL_LINKS_SCRIPT, + }, ] : []), { diff --git a/scripts/aeroflot-url-rewrite.mjs b/scripts/aeroflot-url-rewrite.mjs new file mode 100644 index 00000000..7e24739a --- /dev/null +++ b/scripts/aeroflot-url-rewrite.mjs @@ -0,0 +1,8 @@ +const SAME_ORIGIN_AEROFLOT_ENDPOINTS = + /https:\/\/(?:www\.)?aeroflot\.ru(?=\/(?:ws2|cms2|personal|offers|feedback|pkl|frontend\/static|media)(?:[/?#]|$))/g; + +export function rewriteAeroflotShellUrls(value) { + return value + .replaceAll("https://gw.aeroflot.ru", "/gw") + .replace(SAME_ORIGIN_AEROFLOT_ENDPOINTS, ""); +} diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs index 1f5720be..1e955940 100644 --- a/scripts/dev-server.mjs +++ b/scripts/dev-server.mjs @@ -16,6 +16,7 @@ import { execFile, spawn } from "node:child_process"; import { resolve } from "node:path"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; +import { rewriteAeroflotShellUrls } from "./aeroflot-url-rewrite.mjs"; const PUBLIC_PORT = 8080; const MODERNJS_PORT = 8081; @@ -25,6 +26,7 @@ const AEROFLOT_STATIC_TARGET = process.env.AEROFLOT_STATIC_TARGET || "https://ww const SYSTEM_PROXY = process.env.https_proxy || process.env.HTTPS_PROXY || ""; const DEBUG_PROXY_BODY = process.env.DEBUG_PROXY_BODY === "1"; const LOCAL_PUBLIC_ORIGIN = `http://localhost:${PUBLIC_PORT}`; +const shellStaticAssetCache = new Map(); // Shared cookie jar so the Ngenix WAF cookie challenge (`ngenix_valid` + // 307-to-self) only runs once per dev-server lifetime, not per request. @@ -233,23 +235,86 @@ app.use( pathRewrite: (path) => `/frontend/static${path}`, selfHandleResponse: true, on: { - proxyRes: responseInterceptor(async (buffer, proxyRes) => { - const contentType = proxyRes.headers["content-type"] ?? ""; + proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => { + const cacheKey = req.originalUrl ?? req.url ?? ""; + const contentType = inferShellStaticContentType( + cacheKey, + proxyRes.headers["content-type"], + ); if (!/(css|html|javascript|json|text)/i.test(String(contentType))) { + if (proxyRes.statusCode && proxyRes.statusCode < 500) { + shellStaticAssetCache.set(cacheKey, { body: buffer, contentType }); + } return buffer; } - return buffer - .toString("utf8") - .replaceAll("https://gw.aeroflot.ru", "/gw") - .replaceAll("https://www.aeroflot.ru", "") - .replaceAll("https://aeroflot.ru", ""); + if (proxyRes.statusCode && proxyRes.statusCode >= 500) { + const cached = shellStaticAssetCache.get(cacheKey); + if (cached) { + res.statusCode = 200; + res.statusMessage = "OK"; + res.setHeader("content-type", cached.contentType); + return cached.body; + } + + const retried = await retryAeroflotStaticAsset(cacheKey); + if (retried) { + const body = rewriteAeroflotShellUrls(retried.body); + shellStaticAssetCache.set(cacheKey, { body, contentType }); + res.statusCode = 200; + res.statusMessage = "OK"; + res.setHeader("content-type", contentType); + return body; + } + } + + const body = rewriteAeroflotShellUrls(buffer.toString("utf8")); + if (!proxyRes.statusCode || proxyRes.statusCode < 500) { + shellStaticAssetCache.set(cacheKey, { body, contentType }); + } + return body; }), }, ...(SYSTEM_PROXY ? { agent: new HttpsProxyAgent(SYSTEM_PROXY) } : {}), }), ); +function inferShellStaticContentType(requestPath, upstreamContentType) { + const contentType = String(upstreamContentType ?? ""); + if (/(css|html|javascript|json|text)/i.test(contentType)) return contentType; + const pathname = requestPath.split("?")[0] ?? ""; + if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "application/javascript"; + if (pathname.endsWith(".css")) return "text/css"; + if (pathname.endsWith(".json")) return "application/json"; + if (pathname.endsWith(".html")) return "text/html"; + return contentType; +} + +function retryAeroflotStaticAsset(originalUrl) { + const targetUrl = `${AEROFLOT_STATIC_TARGET}${originalUrl}`; + const buildArgs = (noproxy) => [ + "-sS", + "-f", + "-L", + ...(noproxy ? ["--noproxy", "*"] : []), + "-H", "Accept: */*", + "-H", "User-Agent: Mozilla/5.0", + targetUrl, + ]; + + return new Promise((resolveRetry) => { + runCurlWithoutStatus(buildArgs(true), (direct) => { + if (!direct.err && direct.stdout) { + resolveRetry({ body: direct.stdout }); + return; + } + runCurlWithoutStatus(buildArgs(false), (proxy) => { + resolveRetry(!proxy.err && proxy.stdout ? { body: proxy.stdout } : null); + }); + }); + }); +} + app.use("/personal/services/internal/v.0.0.1/json/get_member_info", (_req, res) => { res.status(200).json({ data: null }); }); @@ -337,6 +402,18 @@ function runCurl(args, cb) { }); } +function runCurlWithoutStatus(args, cb) { + execFile("/usr/bin/curl", args, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 }, (err, stdout, stderr) => { + if (err) { + console.warn(`static curl retry failed: ${err.message}`); + } + if (stderr) { + console.warn(`static curl retry stderr: ${stderr.substring(0, 300)}`); + } + cb({ err, stdout: stdout ?? "" }); + }); +} + function isSuccessfulUpstream({ err, stdout }) { if (err) return false; const lastNewline = stdout.lastIndexOf("\n"); @@ -395,10 +472,7 @@ function respondWithAeroflotFrontendResult(result, res) { const lastNewline = result.stdout.lastIndexOf("\n"); const rawBody = lastNewline >= 0 ? result.stdout.substring(0, lastNewline) : result.stdout; - const body = rawBody - .replaceAll("https://gw.aeroflot.ru", "/gw") - .replaceAll("https://www.aeroflot.ru", "") - .replaceAll("https://aeroflot.ru", ""); + const body = rewriteAeroflotShellUrls(rawBody); const statusStr = lastNewline >= 0 ? result.stdout.substring(lastNewline + 1).trim() : "200"; const status = parseInt(statusStr) || 200; const isJson = body.trimStart().startsWith("{") || body.trimStart().startsWith("["); diff --git a/scripts/standalone-server.mjs b/scripts/standalone-server.mjs index b5dfcb74..19e693aa 100644 --- a/scripts/standalone-server.mjs +++ b/scripts/standalone-server.mjs @@ -14,6 +14,7 @@ import http from "node:http"; import https from "node:https"; import { spawn } from "node:child_process"; import { resolve } from "node:path"; +import { rewriteAeroflotShellUrls } from "./aeroflot-url-rewrite.mjs"; const PUBLIC_PORT = Number(process.env.PORT || 8080); const MODERNJS_PORT = Number(process.env.MODERN_SERVER_PORT || 8081); @@ -49,10 +50,7 @@ const app = express(); const shellResponseCache = new Map(); function rewriteAeroflotUrls(value) { - return value - .replaceAll("https://gw.aeroflot.ru", "/gw") - .replaceAll("https://www.aeroflot.ru", "") - .replaceAll("https://aeroflot.ru", ""); + return rewriteAeroflotShellUrls(value); } function requestOrigin(req) { diff --git a/tests/e2e/standalone-shell.spec.ts b/tests/e2e/standalone-shell.spec.ts index e60dc1eb..a11acdf2 100644 --- a/tests/e2e/standalone-shell.spec.ts +++ b/tests/e2e/standalone-shell.spec.ts @@ -1,17 +1,69 @@ +import type { Page } from "@playwright/test"; import { test, expect } from "./fixtures/console-gate"; +function hasTransientShellLoadError(consoleMessages: string[]): boolean { + return consoleMessages.some( + (message) => + message.includes("afl-frontend-lib.") && + (message.includes("504 (Gateway Timeout)") || + message.includes("Loading chunk")), + ); +} + +async function loadStandaloneShell(page: Page, consoleMessages: string[]): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < 2; attempt += 1) { + consoleMessages.length = 0; + await page.goto("/ru-ru/smoke"); + await page.waitForLoadState("domcontentloaded"); + + try { + await expect(page.getByTestId("standalone-header")).toContainText("Сервисы и услуги", { + timeout: 15_000, + }); + await expect(page.locator('afl-component.footer[data-component="Footer"]')).toContainText( + "Контакты", + { timeout: 15_000 }, + ); + if (!hasTransientShellLoadError(consoleMessages)) return; + } catch (err) { + lastError = err; + if (!hasTransientShellLoadError(consoleMessages)) throw err; + } + } + + if (lastError) throw lastError; +} + test.describe("TIRREDESIGN-30 — standalone header and footer", () => { test("Russian standalone pages render Angular-style Aeroflot shell placeholders", async ({ page, consoleMessages, }) => { - await page.goto("/ru-ru/smoke"); - await page.waitForLoadState("domcontentloaded"); + await loadStandaloneShell(page, consoleMessages); const header = page.getByTestId("standalone-header"); await expect(header.locator('afl-component.header[data-component="Header"]')).toHaveCount(1); await expect(header).toContainText("Сервисы и услуги", { timeout: 15_000 }); await expect(header).toContainText("Личный кабинет", { timeout: 15_000 }); + await expect(header.locator("a", { hasText: "Сервисы и услуги" }).first()).toHaveAttribute( + "href", + "https://www.aeroflot.ru/ru-ru/online_services", + ); + await expect(header.locator("a", { hasText: "Купить билет" }).first()).toHaveAttribute( + "href", + "https://www.aeroflot.ru/ru/booking", + ); + await expect(header.locator("a", { hasText: "Вступить в программу" }).first()).toHaveAttribute( + "href", + "https://www.aeroflot.ru/personal/login?_preferredLanguage=ru", + ); + await expect( + header.locator("a", { + hasText: "Вход в личный кабинет Программы Корпоративной Лояльности", + }).first(), + ).toHaveAttribute("href", "https://www.aeroflot.ru/pkl/app/ru/login"); await expect(page.locator("h1")).toHaveText("Страница проверки"); @@ -19,6 +71,27 @@ test.describe("TIRREDESIGN-30 — standalone header and footer", () => { await expect(footer).toHaveCount(1); await expect(footer).toContainText("Контакты", { timeout: 15_000 }); await expect(footer).toContainText("8 (800) 444-55-55", { timeout: 15_000 }); + await expect(footer.locator("a", { hasText: "Обратная связь" }).first()).toHaveAttribute( + "href", + "https://www.aeroflot.ru/ru-ru/help", + ); + const sameOriginShellLinks = await page + .locator('afl-component.header a[href], afl-component.footer a[href]') + .evaluateAll((anchors) => + anchors + .map((anchor) => ({ + href: anchor.getAttribute("href") ?? "", + text: anchor.textContent?.replace(/\s+/g, " ").trim() ?? "", + })) + .filter((link) => { + try { + return new URL(link.href, window.location.href).origin === window.location.origin; + } catch { + return false; + } + }), + ); + expect(sameOriginShellLinks).toEqual([]); await expect( page.locator('.banner--top afl-component[data-component="BannersOffers"].initialized'), ).toHaveCount(1);