Fix Aeroflot shell navigation links
This commit is contained in:
@@ -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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
|
||||
@@ -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
@@ -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("[");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user