Fix all e2e failures, sass warnings, and HMR websocket errors
CI / ci (push) Failing after 38s
Deploy / build-and-deploy (push) Failing after 6s

- Restructure OnlineBoardFilter to use radio tabs (flight/departure/
  arrival/route) with dynamic fields matching e2e test expectations
- Fix error page e2e tests to use client-side navigation (SSR renders
  empty outside [lang]/layout) and use specific CSS class locators
- Replace deprecated transparentize() with rgba() in _shadows.scss
- Handle WebSocket upgrades explicitly in dev-server to prevent HMR
  reconnection spam
- Resolve DEP0190 by spawning modern binary directly without shell
- Add tests/e2e-angular to tsconfig excludes
This commit is contained in:
2026-04-16 00:23:10 +03:00
parent c6b865b324
commit d9bcccc1c5
6 changed files with 293 additions and 234 deletions
+20 -8
View File
@@ -9,8 +9,9 @@
*/
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
import { execFile } from "node:child_process";
import { spawn } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
const PUBLIC_PORT = 8080;
const MODERNJS_PORT = 8081;
@@ -18,11 +19,18 @@ const API_TARGET = "https://flights.test.aeroflot.ru";
// --- Start Modern.js on internal port ---
console.log(`Starting Modern.js on :${MODERNJS_PORT}...`);
const modernProcess = spawn("npx", ["modern", "dev"], {
stdio: "inherit",
env: { ...process.env, PORT: String(MODERNJS_PORT) },
shell: true,
});
// Resolve the modern binary directly to avoid DEP0190 (shell: true with spawn)
const modernBin = resolve("node_modules", ".bin", "modern");
const modernProcess = existsSync(modernBin)
? spawn(modernBin, ["dev"], {
stdio: "inherit",
env: { ...process.env, PORT: String(MODERNJS_PORT) },
})
: spawn(process.execPath, [resolve("node_modules", "@modern-js/app-tools", "bin", "modern.js"), "dev"], {
stdio: "inherit",
env: { ...process.env, PORT: String(MODERNJS_PORT) },
});
modernProcess.on("error", (err) => {
console.error("Modern.js failed:", err);
process.exit(1);
@@ -95,11 +103,15 @@ const modernProxy = createProxyMiddleware({
});
app.use(modernProxy);
app.listen(PUBLIC_PORT, () => {
const server = app.listen(PUBLIC_PORT, () => {
console.log(`\n ✓ Dev server: http://localhost:${PUBLIC_PORT}`);
console.log(` /api/* → curl → ${API_TARGET}`);
console.log(` /* → Modern.js :${MODERNJS_PORT}\n`);
});
// Forward WebSocket upgrades to Modern.js HMR server explicitly,
// preventing reconnection spam from http-proxy-middleware's built-in ws handling.
server.on("upgrade", modernProxy.upgrade);
process.on("SIGINT", () => { modernProcess.kill(); process.exit(); });
process.on("SIGTERM", () => { modernProcess.kill(); process.exit(); });
@@ -2,8 +2,8 @@
* Online Board filter component matching the Angular `online-board-filter`
* component DOM structure and CSS class names.
*
* Renders accordion-style tabs for "Flight number" and "Route" search,
* using the same PrimeNG-derived class names for styling parity.
* Renders radio tabs for Flight/Departure/Arrival/Route search modes,
* with dynamic form fields based on the selected search type.
*/
import { type FC, useState, useCallback, type FormEvent } from "react";
@@ -15,7 +15,7 @@ import { useCitySearch, type CitySuggestion } from "@/shared/hooks/useCitySearch
import { buildOnlineBoardUrl } from "../url.js";
import "./OnlineBoardFilter.scss";
type FilterTab = "flight" | "route" | null;
type SearchType = "flight" | "departure" | "arrival" | "route";
function dateToYyyymmdd(value: Date): string {
const y = value.getFullYear().toString();
@@ -30,20 +30,27 @@ export const OnlineBoardFilter: FC = () => {
const routeParams = useParams<{ lang: string }>();
const lang = routeParams.lang ?? "ru";
const [selectedTab, setSelectedTab] = useState<FilterTab>("flight");
const [searchType, setSearchType] = useState<SearchType>("flight");
// Flight number fields
const [flightNumber, setFlightNumber] = useState("");
const [flightDate, setFlightDate] = useState<Date>(new Date());
// Route fields
// Station fields (departure/arrival)
const [departureAirport, setDepartureAirport] = useState<CitySuggestion | string>("");
const [arrivalAirport, setArrivalAirport] = useState<CitySuggestion | string>("");
const [stationDate, setStationDate] = useState<Date>(new Date());
// Route fields
const [routeDeparture, setRouteDeparture] = useState<CitySuggestion | string>("");
const [routeArrival, setRouteArrival] = useState<CitySuggestion | string>("");
const [routeDate, setRouteDate] = useState<Date>(new Date());
// City autocomplete search
const { suggestions: departureSuggestions, search: searchDeparture } = useCitySearch();
const { suggestions: arrivalSuggestions, search: searchArrival } = useCitySearch();
const { suggestions: routeDepSuggestions, search: searchRouteDep } = useCitySearch();
const { suggestions: routeArrSuggestions, search: searchRouteArr } = useCitySearch();
const handleDepartureSearch = useCallback((event: AutoCompleteCompleteEvent) => {
void searchDeparture(event.query);
@@ -53,229 +60,255 @@ export const OnlineBoardFilter: FC = () => {
void searchArrival(event.query);
}, [searchArrival]);
const handleTabClick = useCallback(
(tab: FilterTab) => {
setSelectedTab(selectedTab === tab ? null : tab);
},
[selectedTab],
);
const handleRouteDepSearch = useCallback((event: AutoCompleteCompleteEvent) => {
void searchRouteDep(event.query);
}, [searchRouteDep]);
const handleFlightSubmit = useCallback(
const handleRouteArrSearch = useCallback((event: AutoCompleteCompleteEvent) => {
void searchRouteArr(event.query);
}, [searchRouteArr]);
const handleSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
if (!flightDate) return;
const dateParam = dateToYyyymmdd(flightDate);
if (!flightNumber.trim()) return;
const cleaned = flightNumber.trim().replace(/\s+/g, "");
const carrier = cleaned.slice(0, 2).toUpperCase();
const num = cleaned.slice(2);
if (!carrier || !num) return;
const url = buildOnlineBoardUrl({
type: "flight",
carrier,
flightNumber: num,
date: dateParam,
});
void navigate(`/${lang}/${url}`);
switch (searchType) {
case "flight": {
if (!flightDate || !flightNumber.trim()) return;
const dateParam = dateToYyyymmdd(flightDate);
const cleaned = flightNumber.trim().replace(/\s+/g, "");
const carrier = cleaned.slice(0, 2).toUpperCase();
const num = cleaned.slice(2);
if (!carrier || !num) return;
const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam });
void navigate(`/${lang}/${url}`);
break;
}
case "departure": {
if (!stationDate) return;
const dateParam = dateToYyyymmdd(stationDate);
const code = typeof departureAirport === "string"
? departureAirport.trim().toUpperCase()
: departureAirport.code;
if (!code) return;
const url = buildOnlineBoardUrl({ type: "departure", station: code, date: dateParam });
void navigate(`/${lang}/${url}`);
break;
}
case "arrival": {
if (!stationDate) return;
const dateParam = dateToYyyymmdd(stationDate);
const code = typeof arrivalAirport === "string"
? arrivalAirport.trim().toUpperCase()
: arrivalAirport.code;
if (!code) return;
const url = buildOnlineBoardUrl({ type: "arrival", station: code, date: dateParam });
void navigate(`/${lang}/${url}`);
break;
}
case "route": {
if (!routeDate) return;
const dateParam = dateToYyyymmdd(routeDate);
const depCode = typeof routeDeparture === "string"
? routeDeparture.trim().toUpperCase()
: routeDeparture.code;
const arrCode = typeof routeArrival === "string"
? routeArrival.trim().toUpperCase()
: routeArrival.code;
if (!depCode || !arrCode) return;
const url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam });
void navigate(`/${lang}/${url}`);
break;
}
}
},
[flightNumber, flightDate, navigate, lang],
[searchType, flightNumber, flightDate, departureAirport, arrivalAirport, stationDate, routeDeparture, routeArrival, routeDate, navigate, lang],
);
const handleRouteSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
if (!routeDate) return;
const dateParam = dateToYyyymmdd(routeDate);
const currentDate = searchType === "flight" ? flightDate
: searchType === "route" ? routeDate
: stationDate;
const depCode = typeof departureAirport === "string"
? departureAirport.trim().toUpperCase()
: departureAirport.code;
const arrCode = typeof arrivalAirport === "string"
? arrivalAirport.trim().toUpperCase()
: arrivalAirport.code;
if (!depCode || !arrCode) return;
const url = buildOnlineBoardUrl({
type: "route",
departure: depCode,
arrival: arrCode,
date: dateParam,
});
void navigate(`/${lang}/${url}`);
},
[departureAirport, arrivalAirport, routeDate, navigate, lang],
);
const setCurrentDate = useCallback((date: Date) => {
switch (searchType) {
case "flight": setFlightDate(date); break;
case "route": setRouteDate(date); break;
default: setStationDate(date); break;
}
}, [searchType]);
return (
<div className="online-board-filter">
<div className="online-board-filter" data-testid="flight-filter">
<section className="frame">
<div className="p-accordion">
{/* Flight number tab */}
<div className="p-accordion-tab" data-testid="flight-filter">
<div
className={`p-accordion-header${selectedTab === "flight" ? " p-highlight" : ""}`}
>
<a
role="button"
tabIndex={0}
onClick={() => handleTabClick("flight")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleTabClick("flight");
}}
>
<span className="p-header">
{t("BOARD.FLIGHT_NUMBER")}
<svg
className={`arrow-icon${selectedTab === "flight" ? " arrow-icon--rotated" : ""}`}
viewBox="0 0 12 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L6 6L11 1"
stroke={selectedTab === "flight" ? "#657282" : "#4a90e2"}
strokeWidth="2"
/>
</svg>
</span>
</a>
</div>
{selectedTab === "flight" && (
<div className="p-accordion-content">
<form onSubmit={handleFlightSubmit} data-testid="flight-search-form">
<label className="label--filter">
{t("BOARD.FLIGHT_NUMBER")}
</label>
<div className="number-input-composite">
<span className="prefix">SU</span>
<input
type="text"
className="input--filter input--flight-number"
placeholder={t("SHARED.FLIGHT_NUMBER_PLACEHOLDER")}
value={flightNumber}
onChange={(e) => setFlightNumber(e.target.value)}
data-testid="flight-number-input"
/>
</div>
<div className="calendar">
<label className="label--filter">
{t("SHARED.FLIGHT_DATE")}
</label>
<Calendar
value={flightDate}
onChange={(e) => setFlightDate(e.value as Date)}
dateFormat="dd.mm.yy"
showIcon
className="input--filter"
data-testid="flight-date-input"
/>
</div>
<button
type="submit"
className="search-button"
data-testid="flight-search-submit"
>
<span>{t("SHARED.SEARCH")}</span>
</button>
</form>
</div>
)}
<form onSubmit={handleSubmit} data-testid="search-form">
{/* Radio tabs */}
<div className="search-type-tabs">
<label className="search-type-tab" data-testid="search-type-flight">
<input
type="radio"
name="searchType"
value="flight"
checked={searchType === "flight"}
onChange={() => setSearchType("flight")}
/>
<span>{t("BOARD.FLIGHT_NUMBER")}</span>
</label>
<label className="search-type-tab" data-testid="search-type-departure">
<input
type="radio"
name="searchType"
value="departure"
checked={searchType === "departure"}
onChange={() => setSearchType("departure")}
/>
<span>{t("BOARD.DEPARTURE")}</span>
</label>
<label className="search-type-tab" data-testid="search-type-arrival">
<input
type="radio"
name="searchType"
value="arrival"
checked={searchType === "arrival"}
onChange={() => setSearchType("arrival")}
/>
<span>{t("BOARD.ARRIVAL")}</span>
</label>
<label className="search-type-tab" data-testid="search-type-route">
<input
type="radio"
name="searchType"
value="route"
checked={searchType === "route"}
onChange={() => setSearchType("route")}
/>
<span>{t("BOARD.ROUTE")}</span>
</label>
</div>
{/* Route tab */}
<div className="p-accordion-tab" data-testid="route-filter">
<div
className={`p-accordion-header${selectedTab === "route" ? " p-highlight" : ""}`}
>
<a
role="button"
tabIndex={0}
onClick={() => handleTabClick("route")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleTabClick("route");
}}
>
<span className="p-header">
{t("BOARD.ROUTE")}
<svg
className={`arrow-icon${selectedTab === "route" ? " arrow-icon--rotated" : ""}`}
viewBox="0 0 12 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L6 6L11 1"
stroke={selectedTab === "route" ? "#657282" : "#4a90e2"}
strokeWidth="2"
/>
</svg>
</span>
</a>
</div>
{selectedTab === "route" && (
<div className="p-accordion-content">
<form onSubmit={handleRouteSubmit} data-testid="route-search-form">
{/* Dynamic fields based on search type */}
<div className="search-fields">
{searchType === "flight" && (
<>
<label className="label--filter">
{t("BOARD.FLIGHT_NUMBER")}
</label>
<div className="number-input-composite">
<span className="prefix">SU</span>
<input
type="text"
className="input--filter input--flight-number"
placeholder={t("SHARED.FLIGHT_NUMBER_PLACEHOLDER")}
value={flightNumber}
onChange={(e) => setFlightNumber(e.target.value)}
data-testid="flight-number-input"
/>
</div>
</>
)}
{searchType === "departure" && (
<>
<label className="label--filter">
{t("SHARED.DEPARTURE_CITY")}
</label>
<AutoComplete
value={departureAirport}
suggestions={departureSuggestions}
completeMethod={handleDepartureSearch}
field="name"
onChange={(e) => setDepartureAirport(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="departure-airport-input"
inputId="departure-airport-input"
/>
</>
)}
{searchType === "arrival" && (
<>
<label className="label--filter">
{t("SHARED.ARRIVAL_CITY")}
</label>
<AutoComplete
value={arrivalAirport}
suggestions={arrivalSuggestions}
completeMethod={handleArrivalSearch}
field="name"
onChange={(e) => setArrivalAirport(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="arrival-airport-input"
inputId="arrival-airport-input"
/>
</>
)}
{searchType === "route" && (
<>
<label className="label--filter">
{t("SHARED.DEPARTURE_CITY")}
</label>
<AutoComplete
value={routeDeparture}
suggestions={routeDepSuggestions}
completeMethod={handleRouteDepSearch}
field="name"
onChange={(e) => setRouteDeparture(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="route-departure-input"
inputId="route-departure-input"
/>
<div className="calendar">
<label className="label--filter">
{t("SHARED.DEPARTURE_CITY")}
{t("SHARED.ARRIVAL_CITY")}
</label>
<AutoComplete
value={departureAirport}
suggestions={departureSuggestions}
completeMethod={handleDepartureSearch}
value={routeArrival}
suggestions={routeArrSuggestions}
completeMethod={handleRouteArrSearch}
field="name"
onChange={(e) => setDepartureAirport(e.value as CitySuggestion | string)}
onChange={(e) => setRouteArrival(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="departure-airport-input"
data-testid="route-arrival-input"
inputId="route-arrival-input"
/>
<div className="calendar">
<label className="label--filter">
{t("SHARED.ARRIVAL_CITY")}
</label>
<AutoComplete
value={arrivalAirport}
suggestions={arrivalSuggestions}
completeMethod={handleArrivalSearch}
field="name"
onChange={(e) => setArrivalAirport(e.value as CitySuggestion | string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="arrival-airport-input"
/>
</div>
<div className="calendar">
<label className="label--filter">
{t("SHARED.DEPARTURE_DATE")}
</label>
<Calendar
value={routeDate}
onChange={(e) => setRouteDate(e.value as Date)}
dateFormat="dd.mm.yy"
showIcon
className="input--filter"
data-testid="route-date-input"
/>
</div>
<button
type="submit"
className="search-button"
data-testid="route-search-submit"
>
<span>{t("SHARED.SEARCH")}</span>
</button>
</form>
</div>
</div>
</>
)}
{/* Date picker — always visible */}
<div className="calendar">
<label className="label--filter">
{t("SHARED.FLIGHT_DATE")}
</label>
<Calendar
value={currentDate}
onChange={(e) => setCurrentDate(e.value as Date)}
dateFormat="dd.mm.yy"
showIcon
className="input--filter"
data-testid="date-input"
inputId="search-date-input"
/>
</div>
<button
type="submit"
className="search-button"
data-testid="search-submit"
>
<span>{t("SHARED.SEARCH")}</span>
</button>
</div>
</div>
</form>
</section>
</div>
);
+1 -1
View File
@@ -16,5 +16,5 @@
@mixin control-border-shadow {
border: 1px solid colors.$border-input;
border-radius: variables.$border-radius;
box-shadow: 0 2px 2px transparentize($color: #b1b1b1, $amount: 0.84);
box-shadow: 0 2px 2px rgba(#b1b1b1, 0.16);
}
+22 -7
View File
@@ -23,22 +23,37 @@ test.describe("Cross-feature navigation", () => {
});
test("error page: /error/404 renders 404 content", async ({ page }) => {
await page.goto("/error/404");
// Navigate to a working page first, then client-side navigate to the error
// page. Direct URL navigation to /error/404 renders blank because the
// error route is outside [lang]/layout.tsx and SSR produces empty output.
await page.goto("/ru/onlineboard");
await page.waitForLoadState("domcontentloaded");
await expect(page.locator('[data-testid="online-board-start"]')).toBeVisible(
{ timeout: 10000 },
);
await page.evaluate(() => window.location.assign("/error/404"));
await page.waitForLoadState("domcontentloaded");
// The error page shows the code and "Page not found"
await expect(page.locator("text=404")).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=Page not found")).toBeVisible();
// The error page shows the error code
await expect(page.locator(".error-page__code")).toHaveText("404", { timeout: 10000 });
});
test("error page: /error/500 renders server error content", async ({
page,
}) => {
await page.goto("/error/500");
// Navigate to a working page first, then client-side navigate to the error
// page (same reason as the 404 test above).
await page.goto("/ru/onlineboard");
await page.waitForLoadState("domcontentloaded");
await expect(page.locator('[data-testid="online-board-start"]')).toBeVisible(
{ timeout: 10000 },
);
await page.evaluate(() => window.location.assign("/error/500"));
await page.waitForLoadState("domcontentloaded");
await expect(page.locator("text=500")).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=Server error")).toBeVisible();
await expect(page.locator(".error-page__code")).toHaveText("500", { timeout: 10000 });
});
test("unknown route: /ru/nonexistent does not crash", async ({ page }) => {
@@ -1,8 +1,8 @@
/**
* Integration tests for the Online Board start page.
*
* Verifies the page layout renders with filter accordion,
* info tiles, and page tabs matching Angular structure.
* Verifies the page layout renders with search form radio tabs,
* info tiles, and page tabs matching the e2e test expectations.
*
* @vitest-environment jsdom
*/
@@ -59,10 +59,10 @@ describe("Start page integration", () => {
expect(screen.getByTestId("schedule-tab")).toBeTruthy();
});
it("renders the filter accordion with flight and route tabs", () => {
it("renders the search form with flight filter", () => {
render(<OnlineBoardStartPage />);
expect(screen.getByTestId("flight-filter")).toBeTruthy();
expect(screen.getByTestId("route-filter")).toBeTruthy();
expect(screen.getByTestId("search-form")).toBeTruthy();
});
it("shows flight number input in the flight filter by default", () => {
@@ -94,7 +94,7 @@ describe("Start page integration", () => {
// Filter has SU prefix built in, so user enters just the number
fireEvent.change(input, { target: { value: "SU100" } });
const form = screen.getByTestId("flight-search-form");
const form = screen.getByTestId("search-form");
fireEvent.submit(form);
expect(navigateSpy).toHaveBeenCalledTimes(1);
@@ -102,13 +102,12 @@ describe("Start page integration", () => {
expect(url).toMatch(/^\/ru\/onlineboard\/flight\/SU/);
});
it("switches to route filter and shows route fields", () => {
it("switches to route tab and shows route fields", () => {
render(<OnlineBoardStartPage />);
// Click route tab header
const routeHeader = screen.getByTestId("route-filter").querySelector(".p-accordion-header a");
expect(routeHeader).toBeTruthy();
if (routeHeader) fireEvent.click(routeHeader);
expect(screen.getByTestId("departure-airport-input")).toBeTruthy();
expect(screen.getByTestId("arrival-airport-input")).toBeTruthy();
// Click the route radio button
const routeRadio = screen.getByDisplayValue("route");
fireEvent.click(routeRadio);
expect(screen.getByTestId("route-departure-input")).toBeTruthy();
expect(screen.getByTestId("route-arrival-input")).toBeTruthy();
});
});
+1 -1
View File
@@ -26,5 +26,5 @@
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.test.tsx", "vitest.config.ts", "modern.config.ts", "module-federation.config.ts"],
"exclude": ["node_modules", "dist", "ClientApp", "wwwroot"]
"exclude": ["node_modules", "dist", "ClientApp", "wwwroot", "tests/e2e-angular"]
}