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;}
|
if(window.__AFL_SHELL_SHOULD_SUPPRESS__(arguments)){return;}
|
||||||
return window.__AFL_SHELL_CONSOLE_ERROR__.apply(console,arguments);
|
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 modernCommand = process.argv.join(" ");
|
||||||
const npmLifecycleEvent = process.env["npm_lifecycle_event"] ?? "";
|
const npmLifecycleEvent = process.env["npm_lifecycle_event"] ?? "";
|
||||||
@@ -94,6 +120,18 @@ const standaloneShellTags = isRemote
|
|||||||
append: true,
|
append: true,
|
||||||
children: SUPPRESS_AEROFLOT_LOADER_WARNINGS_SCRIPT,
|
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 { resolve } from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { rewriteAeroflotShellUrls } from "./aeroflot-url-rewrite.mjs";
|
||||||
|
|
||||||
const PUBLIC_PORT = 8080;
|
const PUBLIC_PORT = 8080;
|
||||||
const MODERNJS_PORT = 8081;
|
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 SYSTEM_PROXY = process.env.https_proxy || process.env.HTTPS_PROXY || "";
|
||||||
const DEBUG_PROXY_BODY = process.env.DEBUG_PROXY_BODY === "1";
|
const DEBUG_PROXY_BODY = process.env.DEBUG_PROXY_BODY === "1";
|
||||||
const LOCAL_PUBLIC_ORIGIN = `http://localhost:${PUBLIC_PORT}`;
|
const LOCAL_PUBLIC_ORIGIN = `http://localhost:${PUBLIC_PORT}`;
|
||||||
|
const shellStaticAssetCache = new Map();
|
||||||
|
|
||||||
// Shared cookie jar so the Ngenix WAF cookie challenge (`ngenix_valid` +
|
// Shared cookie jar so the Ngenix WAF cookie challenge (`ngenix_valid` +
|
||||||
// 307-to-self) only runs once per dev-server lifetime, not per request.
|
// 307-to-self) only runs once per dev-server lifetime, not per request.
|
||||||
@@ -233,23 +235,86 @@ app.use(
|
|||||||
pathRewrite: (path) => `/frontend/static${path}`,
|
pathRewrite: (path) => `/frontend/static${path}`,
|
||||||
selfHandleResponse: true,
|
selfHandleResponse: true,
|
||||||
on: {
|
on: {
|
||||||
proxyRes: responseInterceptor(async (buffer, proxyRes) => {
|
proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => {
|
||||||
const contentType = proxyRes.headers["content-type"] ?? "";
|
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 (!/(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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return buffer
|
if (proxyRes.statusCode && proxyRes.statusCode >= 500) {
|
||||||
.toString("utf8")
|
const cached = shellStaticAssetCache.get(cacheKey);
|
||||||
.replaceAll("https://gw.aeroflot.ru", "/gw")
|
if (cached) {
|
||||||
.replaceAll("https://www.aeroflot.ru", "")
|
res.statusCode = 200;
|
||||||
.replaceAll("https://aeroflot.ru", "");
|
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) } : {}),
|
...(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) => {
|
app.use("/personal/services/internal/v.0.0.1/json/get_member_info", (_req, res) => {
|
||||||
res.status(200).json({ data: null });
|
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 }) {
|
function isSuccessfulUpstream({ err, stdout }) {
|
||||||
if (err) return false;
|
if (err) return false;
|
||||||
const lastNewline = stdout.lastIndexOf("\n");
|
const lastNewline = stdout.lastIndexOf("\n");
|
||||||
@@ -395,10 +472,7 @@ function respondWithAeroflotFrontendResult(result, res) {
|
|||||||
|
|
||||||
const lastNewline = result.stdout.lastIndexOf("\n");
|
const lastNewline = result.stdout.lastIndexOf("\n");
|
||||||
const rawBody = lastNewline >= 0 ? result.stdout.substring(0, lastNewline) : result.stdout;
|
const rawBody = lastNewline >= 0 ? result.stdout.substring(0, lastNewline) : result.stdout;
|
||||||
const body = rawBody
|
const body = rewriteAeroflotShellUrls(rawBody);
|
||||||
.replaceAll("https://gw.aeroflot.ru", "/gw")
|
|
||||||
.replaceAll("https://www.aeroflot.ru", "")
|
|
||||||
.replaceAll("https://aeroflot.ru", "");
|
|
||||||
const statusStr = lastNewline >= 0 ? result.stdout.substring(lastNewline + 1).trim() : "200";
|
const statusStr = lastNewline >= 0 ? result.stdout.substring(lastNewline + 1).trim() : "200";
|
||||||
const status = parseInt(statusStr) || 200;
|
const status = parseInt(statusStr) || 200;
|
||||||
const isJson = body.trimStart().startsWith("{") || body.trimStart().startsWith("[");
|
const isJson = body.trimStart().startsWith("{") || body.trimStart().startsWith("[");
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import http from "node:http";
|
|||||||
import https from "node:https";
|
import https from "node:https";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
import { rewriteAeroflotShellUrls } from "./aeroflot-url-rewrite.mjs";
|
||||||
|
|
||||||
const PUBLIC_PORT = Number(process.env.PORT || 8080);
|
const PUBLIC_PORT = Number(process.env.PORT || 8080);
|
||||||
const MODERNJS_PORT = Number(process.env.MODERN_SERVER_PORT || 8081);
|
const MODERNJS_PORT = Number(process.env.MODERN_SERVER_PORT || 8081);
|
||||||
@@ -49,10 +50,7 @@ const app = express();
|
|||||||
const shellResponseCache = new Map();
|
const shellResponseCache = new Map();
|
||||||
|
|
||||||
function rewriteAeroflotUrls(value) {
|
function rewriteAeroflotUrls(value) {
|
||||||
return value
|
return rewriteAeroflotShellUrls(value);
|
||||||
.replaceAll("https://gw.aeroflot.ru", "/gw")
|
|
||||||
.replaceAll("https://www.aeroflot.ru", "")
|
|
||||||
.replaceAll("https://aeroflot.ru", "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestOrigin(req) {
|
function requestOrigin(req) {
|
||||||
|
|||||||
@@ -1,17 +1,69 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
import { test, expect } from "./fixtures/console-gate";
|
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.describe("TIRREDESIGN-30 — standalone header and footer", () => {
|
||||||
test("Russian standalone pages render Angular-style Aeroflot shell placeholders", async ({
|
test("Russian standalone pages render Angular-style Aeroflot shell placeholders", async ({
|
||||||
page,
|
page,
|
||||||
consoleMessages,
|
consoleMessages,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/ru-ru/smoke");
|
await loadStandaloneShell(page, consoleMessages);
|
||||||
await page.waitForLoadState("domcontentloaded");
|
|
||||||
|
|
||||||
const header = page.getByTestId("standalone-header");
|
const header = page.getByTestId("standalone-header");
|
||||||
await expect(header.locator('afl-component.header[data-component="Header"]')).toHaveCount(1);
|
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).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("Страница проверки");
|
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).toHaveCount(1);
|
||||||
await expect(footer).toContainText("Контакты", { timeout: 15_000 });
|
await expect(footer).toContainText("Контакты", { timeout: 15_000 });
|
||||||
await expect(footer).toContainText("8 (800) 444-55-55", { 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(
|
await expect(
|
||||||
page.locator('.banner--top afl-component[data-component="BannersOffers"].initialized'),
|
page.locator('.banner--top afl-component[data-component="BannersOffers"].initialized'),
|
||||||
).toHaveCount(1);
|
).toHaveCount(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user