Fix Aeroflot shell navigation links

This commit is contained in:
2026-05-22 11:13:16 +03:00
parent 1158673fdf
commit 6e947f2aa9
5 changed files with 208 additions and 17 deletions
+38
View File
@@ -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,
},
]
: []),
{
+8
View File
@@ -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, "");
}
+85 -11
View File
@@ -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("[");
+2 -4
View File
@@ -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) {
+75 -2
View File
@@ -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<void> {
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);