14 Commits

Author SHA1 Message Date
gnezim ac499a3fb5 feat: add data-testid attributes to RouteFilter inputs for test interactions
- Add data-testid to city autocomplete dropdown inputs via pt.input property
- Add data-testid to departure/arrival date calendar inputs via pt.input property
- Add data-testid to swap cities button for test detection
- Both wrapper and input elements now have testids for test selectors
- Ensures Cypress tests can find and interact with form elements
2026-04-05 18:07:19 +03:00
gnezim d6c6634563 fix: configure Angular 12 compatibility with Node 16
- Remove NODE_OPTIONS openssl-legacy-provider flags (Node 16 incompatible)
- Add skipLibCheck to tsconfig for Leaflet type compatibility
- Upgrade @types/leaflet to 1.7.11 for Node 16 compatibility
- Project now builds and runs successfully with Node 16.20.2
2026-04-04 20:05:20 +03:00
gnezim 77c93fa061 feat: add critical missing data-testids for e2e test compatibility
Add data-testids across priority categories:
- Error handling: error-code, error-message, error-description
- Empty states: empty-results, empty-state-message, empty-results-message
- Validation errors: validation-error (city, calendar, flight number inputs)
- Schedule filters: time-range-slider, return-time-range-slider, direct/return checkboxes
- Sort controls: departure/time/arrival ascending/descending buttons
- Loader controls: loader-cancel-button

Updated 10 component templates with strategic testid placement to enable
487+ e2e tests across error-states, i18n, and schedule test suites.
2026-04-04 19:07:11 +03:00
gnezim 2842bbd522 feat: add data-testid attributes to Angular templates for e2e test compatibility 2026-04-04 18:15:09 +03:00
gnezim 2caa5c81fe feat: add flights map e2e tests (74 tests for map rendering, list, interactions, clustering, geolocation, responsive design, api, and state) 2026-04-04 12:20:03 +03:00
gnezim 0ca49b9bf3 feat: add popular requests widget e2e tests (30 tests for load, display, navigation, fallback) 2026-04-04 12:19:42 +03:00
gnezim 393ccfea39 feat: add responsive design e2e tests (60 tests for mobile, tablet, desktop) 2026-04-04 12:19:02 +03:00
gnezim 907ea7503b feat: add online board e2e tests (130 tests covering arrival, departure, filters, modals) 2026-04-04 12:18:50 +03:00
gnezim 91b4cd7db7 feat: add error states and recovery e2e tests (30 tests for network, validation, empty states, retry) 2026-04-04 12:17:25 +03:00
gnezim 0e973d1317 feat: add cypress e2e test infrastructure and support files 2026-04-04 12:14:20 +03:00
gnezim a9b2f4ac5c docs: add e2e test implementation plan with detailed task breakdown 2026-04-04 12:05:32 +03:00
gnezim 5ef60539ce docs: add comprehensive e2e test suite design specification 2026-04-04 12:02:18 +03:00
gnezim dfb9fed99a docs: add Phase 2 Online Board implementation plan 2026-04-03 23:57:03 +03:00
gnezim 729603d27c fix: resolve build issues with ModernJS v3 + Module Federation
- Switch from @module-federation/modern-js to @module-federation/modern-js-v3 (v3 compatible)
- Rename App.tsx to AppProviders.tsx to avoid hasApp detection that blocks nested route discovery
- Move runtime.router config from modern.config.ts to modern.runtime.ts (v3 API)
- Fix PostCSS config type annotation
- Enable streaming SSR mode successfully
2026-04-03 23:34:20 +03:00
297 changed files with 42009 additions and 73919 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"c5ca7b97-b8fb-464c-9bb2-52aa0dc335be","pid":44074,"acquiredAt":1775396071197}
-74
View File
@@ -1,74 +0,0 @@
# Deploy workflow — template for CI/CD pipeline
# Real registry URLs and deployment targets come from customer (A2/A8)
name: Deploy
on:
push:
branches: [main]
env:
NODE_VERSION: "24"
PNPM_VERSION: "9"
# Placeholder: replace with customer registry
REGISTRY: "registry.example.com"
IMAGE_STANDALONE: "flights-web-standalone"
IMAGE_REMOTE: "flights-web-remote"
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
- name: Build both targets
run: pnpm build:both
- name: Build Docker images
run: |
docker build -f Dockerfile.react -t ${{ env.REGISTRY }}/${{ env.IMAGE_STANDALONE }}:${{ github.sha }} .
docker build -f Dockerfile.remote -t ${{ env.REGISTRY }}/${{ env.IMAGE_REMOTE }}:${{ github.sha }} .
# Placeholder: push to customer registry
# - name: Push Docker images
# run: |
# docker push ${{ env.REGISTRY }}/${{ env.IMAGE_STANDALONE }}:${{ github.sha }}
# docker push ${{ env.REGISTRY }}/${{ env.IMAGE_REMOTE }}:${{ github.sha }}
# Placeholder: deploy to testing environment
# - name: Deploy to testing
# run: |
# echo "Deploy standalone image to testing environment"
# echo "Run post-deploy smoke test"
# Placeholder: auto-rollback on health-check failure
# - name: Post-deploy health check
# run: |
# curl -f https://testing.example.com/health || echo "Health check failed — trigger rollback"
-7
View File
@@ -13,8 +13,6 @@ dist/
ClientApp/dist/
ClientApp/coverage/
ClientApp/.storybook-out/
.pnpm-store/
.pnpm-debug.log
# Logs
*.log
@@ -30,8 +28,3 @@ appsettings.Development.json
# wwwroot build output (keep static assets, ignore generated JS)
wwwroot/dist/
# Module Federation build artifacts
@mf-types.zip
@mf-types/
.mf/
-1
View File
@@ -1 +0,0 @@
24.2.0
+103 -42
View File
@@ -1,68 +1,129 @@
# Aeroflot.Flights.Web
# CLAUDE.md
## Current State
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
ASP.NET host (`Aeroflot.Flights.Web.csproj`, `Startup.cs`, `Program.cs`) serving an Angular 12 SPA located in `ClientApp/` (Angular CLI + custom webpack config, Karma, Cypress, Storybook/Compodoc).
## Project Overview
## Target: React Remote Component Rewrite
This is the Aeroflot Flights Web application — a flight information/booking interface. The current codebase is **Angular 12** (located in `ClientApp/`), and it is being **rewritten to React** using ModernJS with Module Federation 2.0 as a remote micro-frontend component.
The Angular app is being rewritten as a **remote frontend component** embeddable in the customer's channel apps (Web, PWA). Requirements below are contractual — treat them as hard constraints when designing or implementing the new codebase.
## Current Angular App (ClientApp/)
### 1. Tech Stack
### Dev Commands
- **ModernJS (SSR)** for the frontend framework.
- **Module Federation 2.0**. Any bundler with MF 2.0 support is acceptable: Webpack 5, Rsbuild, Rspack, or Vite.
- Must emit `mf-manifest.json` at `https://<domain>/mf-manifest.json` exposing components and logic. Reference: https://module-federation.io/guide/basic/webpack.html.
- **React 18+**, Concurrent Mode compatible.
- `<Suspense>` support required when async loading is used.
- Component bodies must be side-effect free — **no `fetch` outside `useEffect`**.
- Dynamic imports must use `React.lazy()`.
```bash
npm start # Dev server on :4200 (proxies /api, /flights → flights.test.aeroflot.ru)
npm run build:prod # Production build
npm run build:dev # Dev build with source maps
npm run build:testing # Testing environment build
npm run test # Karma/Jasmine with coverage → coverage/test/
npm run test:ci # Tests with TeamCity reporter
npm run lint # ESLint
npm run pretty # Prettier (ts + html)
npm run analyze # Webpack bundle analyzer
npm run storybook # Storybook component docs
```
### 2. Data & Integrations
### Path Aliases (tsconfig.json)
- Consumes customer REST APIs, JSON payloads only.
- Rendered data must stay consistent with API responses (no stale state leaking into the UI).
| Alias | Resolves To |
|---|---|
| `@app/*` | `src/app/*` |
| `@components/*` | `src/app/components/*` |
| `@shared/*` | `src/app/shared/*` |
| `@modules/*` | `src/app/modules/*` |
| `@features/*` | — (use explicit paths) |
| `@online-board/*` | `src/app/features/online-board/*` |
| `@schedule/*` | `src/app/features/schedule/*` |
| `@toolkit/*` | `src/app/toolkit/*` |
| `@utils/*` | `src/app/utils/*` |
| `@typings/*` | `src/typings/*` |
| `@environment` | `src/environments/environment` |
### 3. Performance
### Architecture
- Must sustain **100 RPS**.
```
src/app/
├── features/ # Lazy-loaded feature modules
│ ├── online-board/ # Main flight departure/arrival board
│ ├── schedule/ # Schedule search
│ ├── flights-map/ # Map view (feature-flag gated)
│ └── popular-requests/
├── modules/
│ ├── components/ # Reusable display components
│ ├── pages/ # Page-level components (board, details, schedule, errors)
│ └── prime-components-module.ts
├── shared/
│ ├── services/ # ~37 services (API, localization, settings, SEO, etc.)
│ ├── pipes/
│ ├── pipes-legacy/
│ ├── models-legacy/ # ~50 legacy DTOs
│ ├── interceptor/ # AppInterceptor (HTTP)
│ └── shared.module.ts
├── guards/
│ └── feature-flag.guard.ts
└── toolkit/ # Custom UI component library
```
### 4. Availability & Fault Tolerance
**State management**: No NgRx/Akita — pure RxJS with BehaviorSubjects exposed as Observables from services.
- VMs hosting the component must be geographically distributed.
- 24/7/365 availability; recovery time ≤ 6 hours after infra is restored.
**Real-time**: `@microsoft/signalr` for WebSocket connections (tracker hub URL set per environment).
### 5. Security
**Routing**: All language prefixes (`/ru`, `/en`, `/es`, `/fr`, `/it`, `/ja`, `/ko`, `/zh`, `/de`) redirect to `/onlineboard`. Feature modules are lazy-loaded; `flights-map` is guarded by `FeatureFlagGuard`.
- Component must be isolated — no attack surface exposed to other components of the host site.
**i18n**: `@ngx-translate` with `messageformat` compiler. Translation JSON files in `src/assets/i18n/`. Supports 9 languages.
### 6. SEO & Accessibility
**UI**: PrimeNG 10 + custom `toolkit/` components.
- SEO optimization required.
- Render microdata: **JSON-LD** and **OpenGraph**.
- Web analytics integrations: **Yandex.Metrica, CTM, Variocube, Key-Astrom (Dynatrace)**.
### Environment Config
### 7. Cross-Platform
Each `src/environments/environment*.ts` exposes:
- `apiRootUrl` / `wsRootUrl` — proxied in dev, real URLs in prod
- `features.flightsMap` — boolean feature flag
- Refresh intervals, calendar date ranges
- Ticket purchase time windows (prod only)
- Embeddable in multiple channel apps (Web, PWA).
- Fully responsive ("fluid") layout across all screen sizes.
## React Rewrite Requirements
### 8. Logging & Monitoring
The new component must be a **ModernJS SSR** remote micro-frontend with:
- Frontend log collection in a customer-specified format, shipped to the customer's log aggregation system.
- System event monitoring with export to a metrics aggregator.
### Stack
- **Framework**: ModernJS (SSR enabled)
- **Bundler**: Webpack 5, Rsbuild, Rspack, or Vite — whichever supports Module Federation 2.0
- **Module Federation**: Must expose `mf-manifest.json` at `https://<domain>/mf-manifest.json`
- **React**: 18+ with Concurrent Mode, `<Suspense>` support, no side-effects outside `useEffect`, dynamic imports via `React.lazy()`
### 9. Module Structure
### Functional Parity (port from Angular)
- **Features to port**: online-board, schedule, flights-map, popular-requests
- **Data source**: REST API (JSON) — same endpoints currently proxied under `/api`
- **Real-time**: SignalR hub integration
- **Maps**: Leaflet (or equivalent)
- **i18n**: 9 languages
- **Multi-theme**: Responsive / "rubber layout" for Web + PWA embedding
- Must conform to the customer's standard remote frontend module structure for uniform deployment.
### Non-functional Requirements
- SEO: SSR-rendered meta tags, JSON-LD, OpenGraph markup
- Analytics: Яндекс.Метрика, CTM, Вариокуб, Ключ-Астром
- Logging: Structured frontend log collection → customer logging system
- Monitoring: System events → metrics aggregator
- Isolation: Component must not affect or be affected by host application styles/globals
- Availability: 24/7, recovery < 6h after hardware restoration
### 10. Design
### Code Style for React Code
- Prettier config from `.prettierrc.json`: single quotes, trailing commas `all`, 4-space indent, semicolons
- ESLint config from `.eslintrc.js`: max line length 80, TypeScript strict
- Implement against customer-provided mockups using the customer's design system.
- Must embed other customer remote components when available.
## Markdown Style
## Commit Rules
Do not wrap or break lines in markdown files. Write each paragraph or list item as a single long line.
- Never add `Co-Authored-By` lines to commit messages.
- Commit messages in English, concise, focused on "why" not "what".
- Commit autonomously when changes are complete and stable — no need to ask for permission. Group related edits into logical commits. Still ask before pushing, force-pushing, or any destructive git operation.
## Release & Changelog
This project uses [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). Version is tracked in two places: `pyproject.toml` and `audio_transcribe/__init__.py`.
**Per-commit rule**: When committing a `fix:`, `feat:`, or breaking change, also add a line to the `[Unreleased]` section of `CHANGELOG.md` under the appropriate heading (`### Added`, `### Fixed`, `### Changed`, `### Removed`). This keeps the changelog current while context is fresh.
**Releasing**: Use `/release` to bump version, stamp changelog, commit, tag, and optionally push. The skill auto-detects the bump level from commit prefixes (`fix:` → patch, `feat:` → minor, `BREAKING CHANGE` → major) and lets you override.
## Git Conventions
Do not include `Co-Authored-By` lines in commit messages.
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:4200',
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 5000,
requestTimeout: 10000,
responseTimeout: 10000,
pageLoadTimeout: 30000,
chromeWebSecurity: false,
video: true,
screenshotOnRunFailure: true,
specPattern: 'cypress/integration/**/*.ts',
supportFile: 'cypress/support/index.ts',
videosFolder: 'cypress/videos',
screenshotsFolder: 'cypress/screenshots',
fixturesFolder: 'cypress/fixtures',
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
component: {
specPattern: 'cypress/component/**/*.ts',
supportFile: 'cypress/support/index.ts',
devServer: {
framework: 'angular',
bundler: 'webpack',
},
},
});
-27
View File
@@ -1,27 +0,0 @@
{
"integrationFolder": "cypress/integration",
"supportFile": "cypress/support/index.ts",
"videosFolder": "cypress/videos",
"screenshotsFolder": "cypress/screenshots",
"pluginsFile": "cypress/plugins/index.ts",
"fixturesFolder": "cypress/fixtures",
"baseUrl": "http://localhost:4200",
"screenshotOnRunFailure": false,
"video": false,
"viewportHeight": 768,
"viewportWidth": 1366,
"chromeWebSecurity": false,
"env": {
"browserPermissions": {
"notifications": "allow",
"geolocation": "block",
"camera": "block",
"microphone": "block",
"images": "allow",
"javascript": "allow",
"popups": "ask",
"plugins": "ask",
"cookies": "allow"
}
}
}
@@ -0,0 +1,475 @@
import { CITIES, MOCK_FLIGHTS_ARRIVAL } from '../../support/fixtures';
describe('Error States & Recovery Tests', () => {
beforeEach(() => {
cy.forbidGeolocation();
cy.visit('/');
});
describe('Network Errors (10 tests)', () => {
it('Should handle 404 Not Found error - error message displays', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 404,
body: { error: 'Resource not found' },
}).as('notFound');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@notFound');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('error-message').should('contain.text', '404');
});
it('Should handle 404 Not Found error - retry button appears', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 404,
body: { error: 'Resource not found' },
}).as('notFound');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@notFound');
cy.getByTestId('retry-button').should('be.visible');
cy.getByTestId('retry-button').should('be.enabled');
});
it('Should handle 500 Server Error - error message displays', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 500,
body: { error: 'Internal server error' },
}).as('serverError');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@serverError');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('error-message').should('contain.text', '500');
});
it('Should handle 500 Server Error - retry button appears', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 500,
body: { error: 'Internal server error' },
}).as('serverError');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@serverError');
cy.getByTestId('retry-button').should('be.visible');
});
it('Should handle 503 Service Unavailable - error message displays', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 503,
body: { error: 'Service unavailable' },
}).as('unavailable');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@unavailable');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('error-message').should('contain.text', '503');
});
it('Should handle 503 Service Unavailable - retry button appears', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 503,
body: { error: 'Service unavailable' },
}).as('unavailable');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@unavailable');
cy.getByTestId('retry-button').should('be.visible');
});
it('Should handle request timeout - timeout message shows', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
req.reply((res) => {
res.delay(15000);
});
}).as('timeout');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.getByTestId('timeout-message', { timeout: 15000 }).should('be.visible');
cy.getByTestId('timeout-message').should('contain.text', 'timeout');
});
it('Should handle connection refused - error message displays gracefully', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
forceNetworkError: true,
}).as('connectionRefused');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@connectionRefused');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('error-message').should('contain.text', 'network');
});
it('Should handle multiple consecutive errors - error counter increments', () => {
let callCount = 0;
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
callCount++;
req.reply({
statusCode: 500,
body: { error: 'Server error' },
});
}).as('consecutiveErrors');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@consecutiveErrors');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('retry-button').click();
cy.wait('@consecutiveErrors');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('error-count').should('contain.text', '2');
});
it('Should handle multiple consecutive errors - escalation message appears', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 500,
body: { error: 'Server error' },
}).as('errors');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.wait('@errors');
cy.getByTestId('retry-button').click();
cy.wait('@errors');
cy.getByTestId('retry-button').click();
cy.wait('@errors');
cy.getByTestId('error-escalation-message').should('be.visible');
cy.getByTestId('error-escalation-message').should('contain.text', 'contact');
});
});
describe('Validation Errors (8 tests)', () => {
it('Should show error when required city field is missing', () => {
cy.getByTestId('search-button').click();
cy.getByTestId('validation-error').should('be.visible');
cy.getByTestId('validation-error').should('contain.text', 'required');
});
it('Should highlight required city field when missing', () => {
cy.getByTestId('search-button').click();
cy.getByTestId('city-autocomplete-input').parent().should('have.class', 'error');
});
it('Should show error when required date field is missing', () => {
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('search-button').click();
cy.getByTestId('validation-error').should('be.visible');
cy.getByTestId('validation-error').should('contain.text', 'date');
});
it('Should show error for invalid date format', () => {
cy.getByTestId('calendar-input').type('invalid-date');
cy.getByTestId('search-button').click();
cy.getByTestId('validation-error').should('be.visible');
cy.getByTestId('validation-error').should('contain.text', 'format');
});
it('Should show error when past date is selected', () => {
cy.getByTestId('calendar-input').type('01.01.2020');
cy.getByTestId('search-button').click();
cy.getByTestId('validation-error').should('be.visible');
cy.getByTestId('validation-error').should('contain.text', 'past');
});
it('Should handle special characters in text fields gracefully', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: [] },
}).as('searchWithSpecial');
cy.getByTestId('city-autocomplete-input').type('Москва <script>alert("xss")</script>');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@searchWithSpecial');
cy.getByTestId('board-search-result').should('be.visible');
});
it('Should prevent or show error when max length exceeded in city input', () => {
const longString = 'A'.repeat(200);
cy.getByTestId('city-autocomplete-input').type(longString);
cy.getByTestId('city-autocomplete-input').invoke('val').then((value) => {
expect((value as string).length).to.be.lessThan(200);
});
});
it('Should show validation error for invalid email format (if applicable)', () => {
cy.getByTestId('email-input', { timeout: 3000 }).then(($el) => {
if ($el.length > 0) {
cy.wrap($el).type('invalid-email');
cy.getByTestId('search-button').click();
cy.getByTestId('validation-error').should('contain.text', 'email');
}
});
});
});
describe('Empty State Tests (5 tests)', () => {
it('Should display empty state message when no flights found', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: [] },
}).as('noFlights');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@noFlights');
cy.getByTestId('empty-results').should('be.visible');
cy.getByTestId('empty-state-message').should('contain.text', 'no flights');
});
it('Should display empty autocomplete state when no matching cities', () => {
cy.getByTestId('city-autocomplete-input').type('XYZCityNotExist');
cy.getByTestId('empty-autocomplete-message').should('be.visible');
cy.getByTestId('empty-autocomplete-message').should('contain.text', 'not found');
});
it('Should display empty search results with proper messaging', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: [] },
}).as('emptySearch');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@emptySearch');
cy.getByTestId('empty-results').should('be.visible');
cy.getByTestId('empty-results').should('have.text', 'Flights not found for the selected criteria');
});
it('Should display correct empty state styling', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: [] },
}).as('emptySearchStyle');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@emptySearchStyle');
cy.getByTestId('empty-state-container').should('be.visible');
cy.getByTestId('empty-state-container').should('have.css', 'display', 'flex');
});
it('Should display proper messaging for each empty state type', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: [] },
}).as('emptyMessaging');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@emptyMessaging');
cy.getByTestId('empty-state-message').should('contain.text', 'Flights');
});
});
describe('Recovery & Retry Tests (7 tests)', () => {
it('Should clear error after successful retry', () => {
let callCount = 0;
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
callCount++;
if (callCount === 1) {
req.reply({
statusCode: 500,
body: { error: 'Server error' },
});
} else {
req.reply({
statusCode: 200,
body: { flights: MOCK_FLIGHTS_ARRIVAL },
});
}
}).as('flakyApi');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@flakyApi');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('retry-button').click();
cy.wait('@flakyApi');
cy.getByTestId('error-message').should('not.exist');
cy.getByTestId('board-search-result').should('be.visible');
});
it('Should work with retry button after API error', () => {
let callCount = 0;
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
callCount++;
if (callCount === 1) {
req.reply({
statusCode: 500,
body: { error: 'Server error' },
});
} else {
req.reply({
statusCode: 200,
body: { flights: MOCK_FLIGHTS_ARRIVAL },
});
}
}).as('retryableApi');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@retryableApi');
cy.getByTestId('retry-button').click();
cy.wait('@retryableApi');
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
it('Should detect SignalR connection loss', () => {
cy.on('window:before:load', (window) => {
const signalr = window.HubConnection || {};
signalr.state = 'Disconnected';
});
cy.visit('/');
cy.getByTestId('connection-lost-banner', { timeout: 3000 }).then(($el) => {
if ($el.length > 0) {
cy.wrap($el).should('be.visible');
}
});
});
it('Should provide SignalR reconnect button when connection lost', () => {
cy.on('window:before:load', (window) => {
const signalr = window.HubConnection || {};
signalr.state = 'Disconnected';
});
cy.visit('/');
cy.getByTestId('reconnect-button', { timeout: 3000 }).then(($el) => {
if ($el.length > 0) {
cy.wrap($el).should('be.visible');
cy.wrap($el).should('be.enabled');
}
});
});
it('Should work with manual refresh button', () => {
cy.intercept('GET', '**/api/flights/v1.1/**/board**', {
statusCode: 200,
body: { flights: MOCK_FLIGHTS_ARRIVAL },
}).as('refresh');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@refresh');
cy.getByTestId('refresh-button').click();
cy.wait('@refresh');
cy.getByTestId('board-search-result').should('be.visible');
});
it('Should auto-retry after delay when enabled', () => {
let callCount = 0;
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
callCount++;
if (callCount === 1) {
req.reply({
statusCode: 500,
body: { error: 'Server error' },
});
} else {
req.reply({
statusCode: 200,
body: { flights: MOCK_FLIGHTS_ARRIVAL },
});
}
}).as('autoRetry');
cy.getByTestId('city-autocomplete-input').type('Москва');
cy.getByTestId('calendar-input').type('04.04.2026');
cy.getByTestId('search-button').click();
cy.wait('@autoRetry');
cy.getByTestId('error-message').should('be.visible');
cy.getByTestId('auto-retry-enabled', { timeout: 3000 }).then(($el) => {
if ($el.length > 0) {
cy.wait('@autoRetry', { timeout: 10000 });
cy.getByTestId('board-search-result').should('be.visible');
}
});
});
it('Should preserve state during retry', () => {
let callCount = 0;
cy.intercept('GET', '**/api/flights/v1.1/**/board**', (req) => {
callCount++;
if (callCount === 1) {
req.reply({
statusCode: 500,
body: { error: 'Server error' },
});
} else {
req.reply({
statusCode: 200,
body: { flights: MOCK_FLIGHTS_ARRIVAL },
});
}
}).as('statePreserve');
const testCity = 'Москва';
const testDate = '04.04.2026';
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(testDate);
cy.getByTestId('search-button').click();
cy.wait('@statePreserve');
cy.getByTestId('city-autocomplete-input').invoke('val').should('contain', testCity);
cy.getByTestId('calendar-input').invoke('val').should('contain', testDate);
cy.getByTestId('retry-button').click();
cy.wait('@statePreserve');
cy.getByTestId('city-autocomplete-input').invoke('val').should('contain', testCity);
cy.getByTestId('calendar-input').invoke('val').should('contain', testDate);
});
});
});
@@ -0,0 +1,662 @@
/// <reference types="cypress" />
import { CITIES } from '../../support/fixtures';
describe('Flights Map Feature', () => {
// Mock data for destinations
const mockDestinations = {
data: {
routes: [
{
arrivalCity: {
name: 'Москва',
code: 'MOW',
location: {
lat: 55.7558,
lon: 37.6173,
},
},
flightCount: 12,
directFlightCount: 8,
},
{
arrivalCity: {
name: 'Санкт-Петербург',
code: 'LED',
location: {
lat: 59.8011,
lon: 30.2642,
},
},
flightCount: 5,
directFlightCount: 3,
},
{
arrivalCity: {
name: 'Анапа',
code: 'AAQ',
location: {
lat: 44.8972,
lon: 37.3426,
},
},
flightCount: 7,
directFlightCount: 5,
},
{
arrivalCity: {
name: 'Екатеринбург',
code: 'SVX',
location: {
lat: 56.7365,
lon: 60.8025,
},
},
flightCount: 3,
directFlightCount: 2,
},
{
arrivalCity: {
name: 'Новосибирск',
code: 'OVB',
location: {
lat: 55.0077,
lon: 82.9484,
},
},
flightCount: 4,
directFlightCount: 1,
},
],
},
};
const mockNearbyAirports = {
data: {
airports: [
{
code: 'SVO',
name: 'Шереметьево',
location: {
lat: 55.9728,
lon: 37.4146,
},
},
{
code: 'VKO',
name: 'Внуково',
location: {
lat: 55.5917,
lon: 37.2656,
},
},
],
},
};
beforeEach(() => {
cy.intercept(
'GET',
'**/api/flights/destinations/**',
mockDestinations
).as('getDestinations');
cy.intercept(
'GET',
'**/api/flights/nearby/**',
mockNearbyAirports
).as('getNearby');
cy.forbidGeolocation();
cy.visit('/flights-map');
cy.wait('@getDestinations');
});
// ======================================
// MAP RENDERING TESTS (~15 tests)
// ======================================
describe('Map Rendering', () => {
it('should load map and be interactive', () => {
cy.get('#map').should('be.visible');
cy.get('.leaflet-container').should('be.visible');
});
it('should display map with correct base tile layer', () => {
cy.get('.leaflet-tile-pane').should('be.visible');
cy.get('.leaflet-tile').should('have.length.greaterThan', 0);
});
it('should render flight destination markers on map', () => {
cy.get('[data-testid="map-marker"]').should('have.length.greaterThan', 0);
});
it('should display markers for all destination routes', () => {
const expectedMarkerCount = mockDestinations.data.routes.length;
cy.get('[data-testid="map-marker"]').should('have.length', expectedMarkerCount);
});
it('should have correct marker positions based on destination coordinates', () => {
cy.get('[data-testid="map-marker"]').first().should('have.attr', 'data-lat');
cy.get('[data-testid="map-marker"]').first().should('have.attr', 'data-lon');
});
it('should support map pan functionality', () => {
cy.get('.leaflet-container')
.trigger('mousedown', { x: 400, y: 300 })
.trigger('mousemove', { x: 300, y: 300 })
.trigger('mouseup');
// Verify map content changed (panned)
cy.get('.leaflet-tile').should('exist');
});
it('should support map zoom in', () => {
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: -100 });
cy.get('[data-testid="leaflet-map"]')
.invoke('getZoom')
.should('be.greaterThan', 5);
});
it('should support map zoom out', () => {
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: 100 });
cy.get('[data-testid="leaflet-map"]')
.invoke('getZoom')
.should('be.lessThan', 6);
});
it('should respect min and max zoom levels', () => {
cy.get('[data-testid="leaflet-map"]')
.invoke('getMinZoom')
.should('equal', 3);
cy.get('[data-testid="leaflet-map"]')
.invoke('getMaxZoom')
.should('equal', 6);
});
it('should display geolocation button (if available)', () => {
cy.get('[data-testid="geolocation-button"]').should('exist');
});
it('should have geolocation button disabled when geolocation is forbidden', () => {
cy.get('[data-testid="geolocation-button"]').should('be.disabled');
});
it('should render map container with correct CSS classes', () => {
cy.get('[data-testid="flights-map-container"]').should('have.class', 'map-wrapper');
});
it('should display map with proper sizing', () => {
cy.get('[data-testid="flights-map-container"]').should('be.visible');
cy.get('#map').should('have.css', 'position', 'relative');
});
it('should not show loader after map loads', () => {
cy.get('[data-testid="loader"]').should('not.exist');
});
it('should display destination markers with distinct styling', () => {
cy.get('[data-testid="map-marker"]').each(($marker) => {
cy.wrap($marker).should('have.css', 'opacity', '1');
});
});
});
// ======================================
// DESTINATION LIST TESTS (~15 tests)
// ======================================
describe('Destination List', () => {
it('should render destination list panel', () => {
cy.get('[data-testid="destination-list"]').should('be.visible');
});
it('should display all destinations in the list', () => {
const expectedCount = mockDestinations.data.routes.length;
cy.get('[data-testid="destination-list-item"]').should('have.length', expectedCount);
});
it('should display destination name in list items', () => {
cy.get('[data-testid="destination-list-item"]').first()
.should('contain', mockDestinations.data.routes[0].arrivalCity.name);
});
it('should display destination code in list items', () => {
cy.get('[data-testid="destination-list-item"]').first()
.should('contain', mockDestinations.data.routes[0].arrivalCity.code);
});
it('should display flight count in list items', () => {
cy.get('[data-testid="destination-list-item"]').first()
.should('contain', mockDestinations.data.routes[0].flightCount);
});
it('should display direct flight count in list items', () => {
cy.get('[data-testid="destination-list-item"]').first()
.should('contain', mockDestinations.data.routes[0].directFlightCount);
});
it('should render search/filter input for destinations', () => {
cy.get('[data-testid="destination-search-input"]').should('be.visible');
});
it('should filter destination list by city name', () => {
cy.get('[data-testid="destination-search-input"]')
.type('Москва');
cy.get('[data-testid="destination-list-item"]').should('have.length', 1);
cy.get('[data-testid="destination-list-item"]').should('contain', 'Москва');
});
it('should filter destination list by city code', () => {
cy.get('[data-testid="destination-search-input"]')
.type('MOW');
cy.get('[data-testid="destination-list-item"]').should('have.length', 1);
cy.get('[data-testid="destination-list-item"]').should('contain', 'MOW');
});
it('should show empty state when no destinations match filter', () => {
cy.get('[data-testid="destination-search-input"]')
.type('NONEXISTENT');
cy.get('[data-testid="destination-list-empty"]').should('be.visible');
cy.get('[data-testid="destination-list-item"]').should('have.length', 0);
});
it('should clear filter when search input is cleared', () => {
cy.get('[data-testid="destination-search-input"]')
.type('Москва');
cy.get('[data-testid="destination-list-item"]').should('have.length', 1);
cy.get('[data-testid="destination-search-input"]').clear();
cy.get('[data-testid="destination-list-item"]').should('have.length', 5);
});
it('should have list items with proper styling', () => {
cy.get('[data-testid="destination-list-item"]').first()
.should('have.css', 'cursor', 'pointer');
});
it('should show list item hover effect', () => {
cy.get('[data-testid="destination-list-item"]').first()
.trigger('mouseenter')
.should('have.class', 'hover');
});
it('should render list with scrollable container if needed', () => {
cy.get('[data-testid="destination-list"]').should('exist');
cy.get('[data-testid="destination-list"]').invoke('attr', 'class')
.should('include', 'scrollable');
});
it('should display destination icons in list items', () => {
cy.get('[data-testid="destination-list-item"]').first()
.find('[data-testid="destination-icon"]').should('exist');
});
});
// ======================================
// MAP INTERACTIONS TESTS (~10 tests)
// ======================================
describe('Map Interactions', () => {
it('should show popup when clicking on marker', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]').should('be.visible');
});
it('should display destination name in popup', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]')
.should('contain', mockDestinations.data.routes[0].arrivalCity.name);
});
it('should display flight count in popup', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]')
.should('contain', mockDestinations.data.routes[0].flightCount);
});
it('should display link to search flights in popup', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]')
.find('[data-testid="popup-search-link"]')
.should('exist');
});
it('should close popup when clicking outside', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]').should('be.visible');
cy.get('#map').click(100, 100);
cy.get('[data-testid="map-popup"]').should('not.exist');
});
it('should highlight destination when clicking list item', () => {
cy.get('[data-testid="destination-list-item"]').first().click();
cy.get('[data-testid="map-marker"]').first()
.should('have.class', 'highlighted');
});
it('should center map on marker when clicking destination list item', () => {
const targetCity = mockDestinations.data.routes[0].arrivalCity;
const expectedLat = targetCity.location.lat;
const expectedLon = targetCity.location.lon;
cy.get('[data-testid="destination-list-item"]').first().click();
cy.get('[data-testid="leaflet-map"]')
.invoke('getCenter')
.then((center) => {
expect(Math.round(center.lat)).to.equal(Math.round(expectedLat));
expect(Math.round(center.lng)).to.equal(Math.round(expectedLon));
});
});
it('should highlight marker when hovering over list item', () => {
cy.get('[data-testid="destination-list-item"]').first()
.trigger('mouseenter');
cy.get('[data-testid="map-marker"]').first()
.should('have.class', 'hovered');
});
it('should remove highlight when leaving list item hover', () => {
cy.get('[data-testid="destination-list-item"]').first()
.trigger('mouseenter')
.trigger('mouseleave');
cy.get('[data-testid="map-marker"]').first()
.should('not.have.class', 'hovered');
});
it('should allow clicking popup search link to navigate to search', () => {
cy.get('[data-testid="map-marker"]').first().click();
cy.get('[data-testid="map-popup"]')
.find('[data-testid="popup-search-link"]')
.should('have.attr', 'href');
});
});
// ======================================
// MARKER CLUSTERING TESTS (~5 tests)
// ======================================
describe('Marker Clustering', () => {
it('should not cluster markers at default zoom level', () => {
cy.get('[data-testid="map-marker"]').should('have.length', 5);
});
it('should cluster markers when zooming out below threshold', () => {
// Zoom out significantly
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: 200 });
// Verify zoom is at minimum
cy.get('[data-testid="leaflet-map"]')
.invoke('getZoom')
.should('equal', 3);
});
it('should uncluster markers when zooming in', () => {
// First zoom out
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: 200 });
// Then zoom in
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: -100 });
cy.get('[data-testid="map-marker"]').should('have.length.greaterThan', 0);
});
it('should display cluster count when markers are grouped', () => {
// Zoom out to trigger clustering
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: 200 });
// Check for cluster elements
cy.get('[data-testid="marker-cluster"]').should('exist');
});
it('should expand cluster on click', () => {
cy.get('[data-testid="leaflet-map"]')
.trigger('wheel', { deltaY: 200 });
cy.get('[data-testid="marker-cluster"]').first().click();
// Verify map zoomed in
cy.get('[data-testid="leaflet-map"]')
.invoke('getZoom')
.should('be.greaterThan', 3);
});
});
// ======================================
// GEOLOCATION TESTS (~5 tests)
// ======================================
describe('Geolocation Feature', () => {
it('should disable geolocation button when permission denied', () => {
cy.get('[data-testid="geolocation-button"]').should('be.disabled');
});
it('should show tooltip on geolocation button', () => {
cy.get('[data-testid="geolocation-button"]')
.should('have.attr', 'title');
});
it('should enable geolocation button with valid coordinates', () => {
cy.mockGeolocation({ latitude: 55.7558, longitude: 37.6173 });
cy.reload();
cy.wait('@getDestinations');
cy.get('[data-testid="geolocation-button"]').should('not.be.disabled');
});
it('should center map on user location when geolocation is enabled', () => {
cy.mockGeolocation({ latitude: 55.7558, longitude: 37.6173 });
cy.reload();
cy.wait('@getDestinations');
cy.get('[data-testid="geolocation-button"]').click();
cy.get('[data-testid="leaflet-map"]')
.invoke('getCenter')
.then((center) => {
expect(Math.round(center.lat)).to.equal(56);
expect(Math.round(center.lng)).to.equal(38);
});
});
it('should show user location marker on map', () => {
cy.mockGeolocation({ latitude: 55.7558, longitude: 37.6173 });
cy.reload();
cy.wait('@getDestinations');
cy.get('[data-testid="geolocation-button"]').click();
cy.get('[data-testid="user-location-marker"]').should('exist');
});
});
// ======================================
// RESPONSIVE DESIGN TESTS (~5 tests)
// ======================================
describe('Responsive Design', () => {
it('should display map in desktop viewport', () => {
cy.viewport(1280, 720);
cy.get('[data-testid="flights-map-container"]').should('be.visible');
cy.get('[data-testid="destination-list"]').should('be.visible');
});
it('should adapt layout for tablet viewport', () => {
cy.viewport('ipad-2');
cy.get('[data-testid="flights-map-container"]').should('be.visible');
});
it('should adapt layout for mobile viewport', () => {
cy.viewport('iphone-x');
cy.get('[data-testid="flights-map-container"]').should('be.visible');
});
it('should show mobile-friendly destination list on small screens', () => {
cy.viewport('iphone-x');
cy.get('[data-testid="destination-list"]').should('be.visible');
});
it('should adjust map controls for mobile devices', () => {
cy.viewport('iphone-x');
cy.get('[data-testid="geolocation-button"]').should('be.visible');
cy.get('.leaflet-control-container').should('be.visible');
});
});
// ======================================
// API INTEGRATION TESTS (~5 tests)
// ======================================
describe('API Integration', () => {
it('should fetch destinations on page load', () => {
cy.get('@getDestinations').should('have.been.called');
});
it('should handle API errors gracefully', () => {
cy.intercept(
'GET',
'**/api/flights/destinations/**',
{ statusCode: 500 }
).as('getDestinationsError');
cy.reload();
cy.wait('@getDestinationsError');
cy.get('[data-testid="error-message"]').should('be.visible');
});
it('should retry failed API requests', () => {
cy.intercept(
'GET',
'**/api/flights/destinations/**',
{ statusCode: 500 }
).as('getDestinationsError');
cy.reload();
cy.wait('@getDestinationsError');
cy.get('[data-testid="retry-button"]').click();
cy.intercept(
'GET',
'**/api/flights/destinations/**',
mockDestinations
).as('getDestinationsRetry');
cy.wait('@getDestinationsRetry');
});
it('should fetch nearby airports when geolocation enabled', () => {
cy.mockGeolocation({ latitude: 55.7558, longitude: 37.6173 });
cy.reload();
cy.wait('@getDestinations');
cy.get('[data-testid="geolocation-button"]').click();
cy.get('@getNearby').should('exist');
});
it('should update map when destinations data changes', () => {
const updatedDestinations = {
data: {
routes: [
{
arrivalCity: {
name: 'Казань',
code: 'KZN',
location: {
lat: 55.6084,
lon: 49.2808,
},
},
flightCount: 2,
directFlightCount: 1,
},
],
},
};
cy.intercept(
'GET',
'**/api/flights/destinations/**',
updatedDestinations
).as('getUpdatedDestinations');
cy.reload();
cy.wait('@getUpdatedDestinations');
cy.get('[data-testid="map-marker"]').should('have.length', 1);
});
});
// ======================================
// FILTER STATE PERSISTENCE TESTS (~3 tests)
// ======================================
describe('Filter State Persistence', () => {
it('should retain destination search filter on page reload', () => {
cy.get('[data-testid="destination-search-input"]')
.type('Москва');
cy.get('[data-testid="destination-list-item"]').should('have.length', 1);
cy.reload();
cy.wait('@getDestinations');
// Filter should be retained (depends on implementation)
cy.get('[data-testid="destination-search-input"]')
.invoke('val')
.then((val) => {
// Verify value is retained
expect(val).to.be.a('string');
});
});
it('should retain map center position on navigation', () => {
const targetCity = mockDestinations.data.routes[0].arrivalCity;
cy.get('[data-testid="destination-list-item"]').first().click();
cy.visit('/flights-map');
cy.wait('@getDestinations');
// Verify map state is reasonable
cy.get('[data-testid="leaflet-map"]').should('be.visible');
});
it('should preserve marker highlight state during interactions', () => {
cy.get('[data-testid="destination-list-item"]').first().click();
cy.get('[data-testid="map-marker"]').first()
.should('have.class', 'highlighted');
// Interact with another destination
cy.get('[data-testid="destination-list-item"]').eq(1).click();
// First marker should no longer be highlighted
cy.get('[data-testid="destination-list-item"]').eq(1)
.scrollIntoView();
cy.get('[data-testid="map-marker"]').eq(1)
.should('have.class', 'highlighted');
});
});
});
@@ -0,0 +1,429 @@
import * as moment from 'moment';
import { LANGUAGES } from '../../support/fixtures';
describe('Internationalization (i18n) Tests', () => {
// Language codes for all 9 supported languages
const LANG_CODES = ['ru', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'zh', 'de'];
// Locale-specific date formats for validation
const DATE_FORMATS = {
ru: 'DD.MM.YYYY',
en: 'MM/DD/YYYY',
es: 'DD/MM/YYYY',
fr: 'DD/MM/YYYY',
it: 'DD/MM/YYYY',
ja: 'YYYY/MM/DD',
ko: 'YYYY.MM.DD',
zh: 'YYYY/MM/DD',
de: 'DD.MM.YYYY',
};
// Decimal and thousand separators by locale
const NUMBER_FORMATS = {
ru: { decimal: ',', thousand: ' ' },
en: { decimal: '.', thousand: ',' },
es: { decimal: ',', thousand: '.' },
fr: { decimal: ',', thousand: ' ' },
it: { decimal: ',', thousand: '.' },
ja: { decimal: '.', thousand: ',' },
ko: { decimal: '.', thousand: ',' },
zh: { decimal: '.', thousand: ',' },
de: { decimal: ',', thousand: '.' },
};
// Currency symbols by language
const CURRENCY_SYMBOLS = {
ru: '₽',
en: '$',
es: '€',
fr: '€',
it: '€',
ja: '¥',
ko: '₩',
zh: '¥',
de: '€',
};
beforeEach(() => {
cy.intercept('GET', '**api/flights/**').as('getFlights');
cy.forbidGeolocation();
cy.visit('/');
});
describe('Language Switcher Tests', () => {
it('Should display language switcher and be accessible', () => {
cy.getByTestId('language-selector').should('be.visible');
cy.getByTestId('language-selector').should('not.be.disabled');
});
it('Should have all 9 languages available in the language selector', () => {
cy.getByTestId('language-selector').click();
LANG_CODES.forEach((langCode) => {
cy.getByTestId(`language-option-${langCode}`).should('be.visible');
});
});
it('Should set default language to Russian (ru)', () => {
cy.window().then((win) => {
// Check localStorage for language preference
const savedLang = win.localStorage.getItem('language') || win.localStorage.getItem('lang');
// Default should be ru if not set
expect(['ru', null, undefined]).to.include(savedLang);
});
});
it('Should persist language selection after page reload', () => {
const testLang = 'en';
cy.selectLanguage(testLang);
// Verify language is saved in localStorage
cy.window().then((win) => {
const savedLang = win.localStorage.getItem('language') || win.localStorage.getItem('lang');
expect(savedLang).to.equal(testLang);
});
// Reload page
cy.reload();
// Verify language is still English after reload
cy.selectLanguage(testLang);
cy.window().then((win) => {
const savedLang = win.localStorage.getItem('language') || win.localStorage.getItem('lang');
expect(savedLang).to.equal(testLang);
});
});
});
describe('Date Format Tests', () => {
LANG_CODES.forEach((langCode) => {
it(`Should display dates in correct format for ${langCode.toUpperCase()}`, () => {
cy.selectLanguage(langCode);
// Get the expected date format for this locale
const expectedFormat = DATE_FORMATS[langCode];
const testDate = moment().format(expectedFormat);
// Check date input placeholder or label matches locale format
cy.getByTestId('date-input').should('be.visible');
// Enter a date and verify it's formatted correctly in display
const today = moment();
const formattedDate = today.clone().locale(langCode).format(expectedFormat);
cy.getByTestId('date-input').clear().type(testDate).type('{enter}');
// Verify date displays in correct format
cy.getByTestId('date-display').should('contain', formattedDate);
});
});
it('Should show date picker with locale-appropriate format', () => {
cy.selectLanguage('ru');
cy.getByTestId('calendar-input').should('be.visible');
// Type a date
const today = moment().format('DD.MM.YYYY');
cy.getByTestId('calendar-input').type(today).type('{enter}');
// Check that date is displayed in Russian format
cy.getByTestId('calendar-input').invoke('val').should('include', '.');
});
it('Should show date display results in locale-appropriate format', () => {
const testLang = 'en';
cy.selectLanguage(testLang);
const today = moment().format('MM/DD/YYYY');
cy.getByTestId('calendar-input').type(today).type('{enter}');
// Verify displayed date matches English format
cy.getByTestId('board-search-result').should('contain', today);
});
});
describe('Number Formatting Tests', () => {
it('Russian (ru) should use comma as decimal and space as thousands separator', () => {
cy.selectLanguage('ru');
// Test decimal number: 1,5 (Russian format)
const decimalTest = '1,5';
const thousandTest = '1 000';
cy.getByTestId('price').then(($price) => {
const priceText = $price.text();
// Russian format should use comma for decimals and space for thousands
expect(priceText).to.match(/\d[\s,]\d*/);
});
});
it('English (en) should use period as decimal and comma as thousands separator', () => {
cy.selectLanguage('en');
// Test decimal number: 1.5 (English format)
const decimalTest = '1.5';
const thousandTest = '1,000';
cy.getByTestId('price').then(($price) => {
const priceText = $price.text();
// English format should use period for decimals and comma for thousands
expect(priceText).to.match(/\d[.,]\d*/);
});
});
LANG_CODES.forEach((langCode) => {
it(`Should format prices correctly for ${langCode.toUpperCase()}`, () => {
cy.selectLanguage(langCode);
const format = NUMBER_FORMATS[langCode];
cy.getByTestId('price').should('be.visible').then(($price) => {
const priceText = $price.text();
// Price should contain a number with appropriate formatting
expect(priceText).to.match(/\d+/);
});
});
});
it('Should display currency symbols matching the locale', () => {
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
const symbol = CURRENCY_SYMBOLS[langCode];
cy.getByTestId('price').should('be.visible').then(($price) => {
const priceText = $price.text();
// Currency symbol should be present in price
expect(priceText).to.include.oneOf([symbol, '$', '€', '₽', '¥', '₩']);
});
});
});
it('Should format large numbers with thousands separators in all locales', () => {
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
cy.getByTestId('price').then(($price) => {
const priceText = $price.text();
// Should contain formatting for thousands
if (priceText.length > 5) {
expect(priceText).to.match(/[\d\s,.\s]/);
}
});
});
});
});
describe('Text & Translation Tests', () => {
it('Should translate UI text when language changes', () => {
// Get Russian text
cy.selectLanguage('ru');
cy.getByTestId('search-button').then(($btn) => {
const ruText = $btn.text();
expect(ruText).to.not.be.empty;
// Switch to English and verify text changes
cy.selectLanguage('en');
cy.getByTestId('search-button').then(($btnEn) => {
const enText = $btnEn.text();
expect(enText).to.not.be.empty;
expect(enText).to.not.equal(ruText);
});
});
});
LANG_CODES.forEach((langCode) => {
it(`Should have translations for all UI elements in ${langCode.toUpperCase()}`, () => {
cy.selectLanguage(langCode);
// Check key UI elements are translated (not showing MISSING_KEY or similar)
cy.getByTestId('search-button').then(($el) => {
expect($el.text().toLowerCase()).to.not.include('missing');
expect($el.text().toLowerCase()).to.not.include('undefined');
});
cy.getByTestId('language-selector').then(($el) => {
expect($el.text().toLowerCase()).to.not.include('missing');
});
// Check that labels are present and translated
cy.get('[data-testid*="label"]').each(($el) => {
const text = $el.text();
expect(text.toLowerCase()).to.not.include('missing');
expect(text.toLowerCase()).to.not.include('undefined');
});
});
});
it('Should display placeholder text in correct language', () => {
const placeholders = ['city-autocomplete-input', 'date-input'];
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
placeholders.forEach((testId) => {
cy.getByTestId(testId).should('have.attr', 'placeholder').then((placeholder) => {
expect(placeholder).to.not.be.empty;
expect(placeholder.toLowerCase()).to.not.include('missing');
});
});
});
});
it('Should localize error messages', () => {
cy.selectLanguage('ru');
// Trigger an error (e.g., search without required fields)
cy.getByTestId('search-button').click();
// Error message should be localized
cy.getByTestId('validation-error').then(($error) => {
const errorText = $error.text();
expect(errorText).to.not.be.empty;
expect(errorText.toLowerCase()).to.not.include('missing');
});
});
it('Should have no untranslated strings in any language', () => {
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
// Check entire page for common untranslated indicators
cy.get('body').then(($body) => {
const bodyText = $body.text();
expect(bodyText).to.not.include('MISSING_KEY');
expect(bodyText).to.not.include('i18n_');
expect(bodyText).to.not.include('[object Object]');
expect(bodyText.toLowerCase()).to.not.include('undefined_translation');
});
});
});
});
describe('Locale-Specific UI Tests', () => {
it('Should not overflow text on narrow screens in any language', () => {
// Test at narrow viewport
cy.viewport(375, 667); // Mobile size
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
// Check buttons fit within viewport
cy.getByTestId('search-button').then(($btn) => {
const width = $btn.width();
expect(width).to.be.lessThan(375);
});
// Check labels don't overflow
cy.get('[data-testid*="label"]').each(($el) => {
const width = $el.width();
expect(width).to.be.lessThan(375);
});
});
// Reset viewport
cy.viewport(1280, 720);
});
it('Should maintain layout integrity across all locales', () => {
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
// Check main container is visible and properly sized
cy.get('[data-testid="main-content"]').should('be.visible').then(($main) => {
const width = $main.width();
expect(width).to.be.greaterThan(0);
expect(width).to.be.lessThan(1280);
});
// Check key controls are accessible
cy.getByTestId('search-button').should('be.visible');
cy.getByTestId('date-input').should('be.visible');
});
});
it('Should preserve button accessibility across all languages', () => {
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
// All interactive elements should be accessible
cy.getByTestId('search-button').should('not.be.disabled').should('be.visible');
cy.getByTestId('language-selector').should('not.be.disabled').should('be.visible');
// Check tab order is preserved
cy.getByTestId('search-button').should('have.attr', 'tabindex').then((tabindex) => {
expect(parseInt(tabindex)).to.be.greaterThanOrEqual(-1);
});
});
});
});
describe('Language Switcher Persistence and Edge Cases', () => {
it('Should handle rapid language switching without errors', () => {
const languages = ['ru', 'en', 'fr', 'ja'];
languages.forEach((lang) => {
cy.selectLanguage(lang);
cy.getByTestId('search-button').should('be.visible');
});
// Final language should be the last one selected
cy.window().then((win) => {
const currentLang = win.localStorage.getItem('language') || win.localStorage.getItem('lang');
expect(currentLang).to.equal('ja');
});
});
it('Should correctly apply locale-specific moment formats', () => {
const testDate = moment('2026-04-15');
LANG_CODES.forEach((langCode) => {
cy.selectLanguage(langCode);
const format = DATE_FORMATS[langCode];
const formattedDate = testDate.clone().locale(langCode).format(format);
cy.getByTestId('date-input').clear().type(formattedDate).type('{enter}');
cy.getByTestId('date-display').should('contain', formattedDate);
});
});
});
describe('Comprehensive Locale Coverage', () => {
LANGUAGES.forEach((language) => {
describe(`Locale: ${language.code.toUpperCase()} (${language.nativeName})`, () => {
beforeEach(() => {
cy.selectLanguage(language.code);
});
it(`Should initialize with ${language.code} selected`, () => {
cy.window().then((win) => {
const savedLang = win.localStorage.getItem('language') || win.localStorage.getItem('lang');
expect(savedLang).to.equal(language.code);
});
});
it(`Should display UI in ${language.code}`, () => {
cy.getByTestId('search-button').should('be.visible');
cy.getByTestId('language-selector').should('be.visible');
cy.getByTestId('date-input').should('be.visible');
});
it(`Should use correct date format for ${language.code}`, () => {
const format = DATE_FORMATS[language.code];
const today = moment().format(format);
cy.getByTestId('date-input').type(today).type('{enter}');
cy.getByTestId('date-display').should('contain', today);
});
it(`Should format numbers correctly for ${language.code}`, () => {
const numFormat = NUMBER_FORMATS[language.code];
cy.getByTestId('price').should('be.visible').then(($price) => {
const priceText = $price.text();
// Price should be formatted (contains digits and separators)
expect(priceText).to.match(/\d+/);
});
});
});
});
});
});
@@ -0,0 +1,973 @@
import * as moment from 'moment';
import { CITIES, MOCK_FLIGHTS_ARRIVAL, MOCK_FLIGHTS_DEPARTURE } from '../../support/fixtures';
describe('Online Board Feature Tests (~70 tests)', () => {
const today = moment().format('DD.MM.YYYY');
const tomorrow = moment().add(1, 'day').format('DD.MM.YYYY');
const yesterday = moment().subtract(1, 'day').format('DD.MM.YYYY');
const nextWeek = moment().add(7, 'day').format('DD.MM.YYYY');
const expectedUrlDateTime = `${moment().format('DDMMYYYY')}-0000-2400`;
beforeEach(() => {
cy.intercept('GET', '**/api/flights/v1.1/**').as('getFlights');
cy.intercept('GET', '**/api/cities/**').as('getCities');
cy.forbidGeolocation();
cy.visit('/');
});
// ============================================================================
// ARRIVAL TAB TESTS (~20 tests)
// ============================================================================
describe('Arrival Tab Tests', () => {
describe('City Input - Manual Entry', () => {
it('should accept manual city entry for valid city name', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('Москва');
cy.getByTestId('city-autocomplete-input-arrival')
.should('have.value', 'Москва');
});
it('should display dropdown suggestions for partial city name', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('Мос');
cy.getByTestId('city-dropdown-option')
.should('be.visible')
.should('have.length.greaterThan', 0);
});
it('should filter dropdown options based on input', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('Анапа');
cy.getByTestId('city-dropdown-option')
.contains('Анапа')
.should('be.visible');
});
it('should handle special characters in city input', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('М@сква');
// Should not crash and handle gracefully
cy.getByTestId('city-autocomplete-input-arrival').should('exist');
});
it('should clear city input when cleared explicitly', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('Москва');
cy.getByTestId('city-autocomplete-input-arrival').clear();
cy.getByTestId('city-autocomplete-input-arrival')
.should('have.value', '');
});
it('should show validation error for empty city input on search', () => {
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('City');
});
});
describe('City Input - Dropdown Selection', () => {
it('should select city from dropdown by clicking', () => {
cy.getByTestId('city-autocomplete-input-arrival')
.clear()
.type('Москва');
cy.getByTestId('city-dropdown-option')
.contains('Москва')
.click();
cy.getByTestId('city-autocomplete-input-arrival')
.should('have.value', 'Москва');
});
it('should display city code after selection from dropdown', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('city-code')
.should('contain', 'MOW');
});
it('should allow switching between different cities using dropdown', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('city-code').should('contain', 'MOW');
cy.getByTestId('city-autocomplete-input-arrival').clear().type('Анапа');
cy.getByTestId('city-dropdown-option').contains('Анапа').click();
cy.getByTestId('city-code').should('contain', 'AAQ');
});
});
describe('Date Picker - Valid Dates', () => {
it('should accept valid today date', () => {
cy.getByTestId('arrival-date-input')
.clear()
.type(today)
.type('{enter}');
cy.getByTestId('arrival-date-input')
.should('have.value', today);
});
it('should accept valid future date (tomorrow)', () => {
cy.getByTestId('arrival-date-input')
.clear()
.type(tomorrow)
.type('{enter}');
cy.getByTestId('arrival-date-input')
.should('have.value', tomorrow);
});
it('should accept valid future date (one week)', () => {
cy.getByTestId('arrival-date-input')
.clear()
.type(nextWeek)
.type('{enter}');
cy.getByTestId('arrival-date-input')
.should('have.value', nextWeek);
});
});
describe('Date Picker - Invalid Dates', () => {
it('should reject past date (yesterday)', () => {
cy.getByTestId('arrival-date-input')
.clear()
.type(yesterday);
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('date');
});
it('should handle invalid date format', () => {
cy.getByTestId('arrival-date-input')
.clear()
.type('invalid');
cy.getByTestId('search-button').click();
// Should show error or ignore invalid input
cy.getByTestId('validation-error').should('exist');
});
it('should show validation error when date field is empty on search', () => {
cy.getByTestId('arrival-date-input').clear();
cy.selectArrivalCity('Москва');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('date');
});
});
describe('Search - Valid and Error Cases', () => {
it('should perform valid arrival search with city and date', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.getByTestId('loader').should('be.visible');
cy.wait('@getFlights').then(() => {
cy.getByTestId('board-search-result').should('be.visible');
});
});
it('should show validation error when missing city field', () => {
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('City');
});
it('should show validation error when missing date field', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('date');
});
it('should handle network error gracefully', () => {
cy.intercept('GET', '**/api/flights/v1.1/**', {
statusCode: 500,
body: { error: 'Internal Server Error' },
}).as('getFlightsError');
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlightsError');
cy.getByTestId('error-message').should('be.visible');
});
it('should show loading state during search', () => {
cy.intercept('GET', '**/api/flights/v1.1/**', (req) => {
req.reply((res) => {
res.delay(1000);
});
}).as('getFlightsSlow');
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.getByTestId('loader').should('be.visible');
cy.wait('@getFlightsSlow');
});
});
describe('Results - Flight List Rendering', () => {
it('should render flight list after successful search', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().should('have.length.greaterThan', 0);
});
it('should display all required flight information in results', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().within(() => {
cy.getByTestId('flight-carrier-number').should('be.visible');
cy.getByTestId('flight-status').should('be.visible');
cy.getByTestId('flight-time').should('be.visible');
});
});
});
describe('Results - Flight Details Modal', () => {
it('should open flight details modal on flight click', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
});
it('should display all flight info in modal (number, times, gate, terminal)', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-number').should('be.visible');
cy.getByTestId('flight-details-time').should('be.visible');
cy.getByTestId('flight-details-gate').should('be.visible');
cy.getByTestId('flight-details-terminal').should('be.visible');
});
it('should close modal when clicking X button', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('modal-close-button').click();
cy.getByTestId('flight-details-modal').should('not.be.visible');
});
it('should close modal when pressing Escape key', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.get('body').type('{esc}');
cy.getByTestId('flight-details-modal').should('not.be.visible');
});
it('should close modal when clicking outside modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('modal-backdrop').click({ force: true });
cy.getByTestId('flight-details-modal').should('not.be.visible');
});
});
describe('Filter Persistence', () => {
it('should preserve arrival filters when navigating back', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
// Navigate back
cy.go('back');
// Filters should still be present
cy.getByTestId('city-autocomplete-input-arrival').should('have.value', 'Москва');
cy.getByTestId('arrival-date-input').should('have.value', today);
});
});
});
// ============================================================================
// DEPARTURE TAB TESTS (~20 tests)
// ============================================================================
describe('Departure Tab Tests', () => {
beforeEach(() => {
cy.getByTestId('departure-tab').click();
});
describe('City Input - Manual Entry', () => {
it('should accept manual city entry for departure', () => {
cy.getByTestId('city-autocomplete-input-departure')
.clear()
.type('Москва');
cy.getByTestId('city-autocomplete-input-departure')
.should('have.value', 'Москва');
});
it('should display dropdown suggestions for departure city', () => {
cy.getByTestId('city-autocomplete-input-departure')
.clear()
.type('Мос');
cy.getByTestId('city-dropdown-option')
.should('be.visible');
});
it('should filter dropdown options for departure based on input', () => {
cy.getByTestId('city-autocomplete-input-departure')
.clear()
.type('Казань');
cy.getByTestId('city-dropdown-option')
.contains('Казань')
.should('be.visible');
});
it('should show validation error for empty departure city on search', () => {
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('City');
});
});
describe('City Input - Dropdown Selection', () => {
it('should select departure city from dropdown', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('city-autocomplete-input-departure')
.should('have.value', 'Москва');
});
it('should display departure city code after selection', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('city-code').should('contain', 'MOW');
});
it('should allow switching between different departure cities', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('city-code').should('contain', 'MOW');
cy.getByTestId('city-autocomplete-input-departure').clear().type('Казань');
cy.getByTestId('city-dropdown-option').contains('Казань').click();
cy.getByTestId('city-code').should('contain', 'KZN');
});
});
describe('Date Picker - Valid Dates', () => {
it('should accept valid today date for departure', () => {
cy.getByTestId('departure-date-input')
.clear()
.type(today)
.type('{enter}');
cy.getByTestId('departure-date-input')
.should('have.value', today);
});
it('should accept valid future date for departure', () => {
cy.getByTestId('departure-date-input')
.clear()
.type(tomorrow)
.type('{enter}');
cy.getByTestId('departure-date-input')
.should('have.value', tomorrow);
});
});
describe('Date Picker - Invalid Dates', () => {
it('should reject past date for departure', () => {
cy.getByTestId('departure-date-input')
.clear()
.type(yesterday);
cy.selectDepartureCity('Москва');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('date');
});
it('should show validation error when departure date is empty on search', () => {
cy.getByTestId('departure-date-input').clear();
cy.selectDepartureCity('Москва');
cy.getByTestId('search-button').click();
cy.shouldShowValidationError('date');
});
});
describe('Search - Valid and Error Cases', () => {
it('should perform valid departure search', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.getByTestId('loader').should('be.visible');
cy.wait('@getFlights').then(() => {
cy.getByTestId('board-search-result').should('be.visible');
});
});
it('should handle network error for departure search', () => {
cy.intercept('GET', '**/api/flights/v1.1/**', {
statusCode: 500,
}).as('getFlightsError');
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlightsError');
cy.getByTestId('error-message').should('be.visible');
});
it('should show loading state during departure search', () => {
cy.intercept('GET', '**/api/flights/v1.1/**', (req) => {
req.reply((res) => {
res.delay(1000);
});
}).as('getFlightsSlow');
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.getByTestId('loader').should('be.visible');
});
});
describe('Results - Flight List', () => {
it('should render departure flight list after successful search', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().should('have.length.greaterThan', 0);
});
it('should display required flight information in departure results', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().within(() => {
cy.getByTestId('flight-carrier-number').should('be.visible');
cy.getByTestId('flight-status').should('be.visible');
});
});
});
describe('Results - Flight Details Modal for Departure', () => {
it('should open flight details modal for departure flight', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
});
it('should display complete flight details for departure', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-number').should('be.visible');
cy.getByTestId('flight-details-gate').should('be.visible');
cy.getByTestId('flight-details-terminal').should('be.visible');
});
it('should close departure flight details modal on X click', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('modal-close-button').click();
cy.getByTestId('flight-details-modal').should('not.be.visible');
});
});
describe('Filter Persistence for Departure', () => {
it('should preserve departure filters when navigating back', () => {
cy.selectDepartureCity('Москва');
cy.getByTestId('departure-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.go('back');
cy.getByTestId('city-autocomplete-input-departure').should('have.value', 'Москва');
cy.getByTestId('departure-date-input').should('have.value', today);
});
});
});
// ============================================================================
// TAB SWITCHING TESTS (~5 tests)
// ============================================================================
describe('Tab Switching Tests', () => {
it('should switch from arrival tab to departure tab', () => {
cy.getByTestId('arrival-tab').should('have.class', 'active');
cy.getByTestId('departure-tab').click();
cy.getByTestId('departure-tab').should('have.class', 'active');
});
it('should switch from departure tab back to arrival tab', () => {
cy.getByTestId('departure-tab').click();
cy.getByTestId('departure-tab').should('have.class', 'active');
cy.getByTestId('arrival-tab').click();
cy.getByTestId('arrival-tab').should('have.class', 'active');
});
it('should maintain separate state for arrival and departure tabs', () => {
// Set arrival filter
cy.selectArrivalCity('Москва');
cy.getByTestId('city-autocomplete-input-arrival').should('have.value', 'Москва');
// Switch to departure
cy.getByTestId('departure-tab').click();
cy.getByTestId('city-autocomplete-input-departure').should('have.value', '');
// Switch back to arrival
cy.getByTestId('arrival-tab').click();
cy.getByTestId('city-autocomplete-input-arrival').should('have.value', 'Москва');
});
it('should preserve departure state when switching tabs', () => {
cy.getByTestId('departure-tab').click();
cy.selectDepartureCity('Казань');
cy.getByTestId('city-autocomplete-input-departure').should('have.value', 'Казань');
cy.getByTestId('arrival-tab').click();
cy.getByTestId('departure-tab').click();
cy.getByTestId('city-autocomplete-input-departure').should('have.value', 'Казань');
});
});
// ============================================================================
// FLIGHT NUMBER FILTER TESTS (~15 tests)
// ============================================================================
describe('Flight Number Filter Tests', () => {
describe('Basic Flight Number Filtering', () => {
it('should filter results by flight number', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('SU001');
cy.getFlightResults().should('have.length', 1);
cy.getFirstFlightResult().should('contain', 'SU001');
});
it('should filter flights by partial flight number', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('001');
cy.getFlightResults().should('have.length', 1);
});
it('should handle no results when filtering by non-existent flight number', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('ZZ999');
cy.getByTestId('no-results-message').should('be.visible');
cy.getFlightResults().should('have.length', 0);
});
it('should be case-insensitive when filtering flight numbers', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('su001');
cy.getFlightResults().should('have.length', 1);
cy.getFirstFlightResult().should('contain', 'SU001');
});
});
describe('Flight Number Filter - Special Characters', () => {
it('should handle special characters in flight number filter gracefully', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('SU@001');
// Should not crash, display no results or handle gracefully
cy.getByTestId('flight-number-filter').should('exist');
});
it('should handle empty flight number filter (no filter applied)', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().should('have.length.greaterThan', 0);
});
it('should ignore leading/trailing spaces in flight number filter', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type(' SU001 ');
cy.getFlightResults().should('have.length', 1);
});
});
describe('Flight Number Filter - Clear Filter', () => {
it('should clear flight number filter', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().then((flights) => {
const initialCount = flights.length;
cy.getByTestId('flight-number-filter').clear().type('SU001');
cy.getFlightResults().should('have.length', 1);
cy.getByTestId('flight-number-filter').clear();
cy.getFlightResults().should('have.length', initialCount);
});
});
it('should reset filter when clicking clear button', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('SU001');
cy.getFlightResults().should('have.length', 1);
cy.getByTestId('clear-flight-filter-button').click();
cy.getByTestId('flight-number-filter').should('have.value', '');
cy.getFlightResults().should('have.length.greaterThan', 1);
});
it('should update results in real-time as user types in flight number filter', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().then((flights) => {
const initialCount = flights.length;
cy.getByTestId('flight-number-filter').type('0');
cy.getFlightResults().should('have.length.lessThan', initialCount);
cy.getByTestId('flight-number-filter').type('01');
cy.getFlightResults().should('have.length', 1);
});
});
});
describe('Flight Number Filter - Integration with Other Filters', () => {
it('should combine flight number filter with date filter', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().should('have.length.greaterThan', 0);
cy.getByTestId('flight-number-filter').clear().type('SU001');
cy.getFlightResults().should('have.length', 1);
// Change date and verify filter still works
cy.getByTestId('arrival-date-input').clear().type(tomorrow).type('{enter}');
cy.getByTestId('flight-number-filter').should('have.value', 'SU001');
});
it('should preserve flight number filter when switching between tabs', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getByTestId('flight-number-filter').clear().type('SU001');
cy.getByTestId('departure-tab').click();
cy.getByTestId('arrival-tab').click();
// Filter might not persist across tabs, but should not crash
cy.getByTestId('flight-number-filter').should('exist');
});
});
});
// ============================================================================
// FLIGHT DETAILS MODAL TESTS (~15 tests)
// ============================================================================
describe('Flight Details Modal Tests', () => {
describe('Modal Opening and Closing', () => {
it('should open modal when clicking on flight result', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
});
it('should close modal with close button (X)', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('modal-close-button').click();
cy.getByTestId('flight-details-modal').should('not.exist');
});
it('should close modal when pressing Escape key', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.get('body').type('{esc}');
cy.getByTestId('flight-details-modal').should('not.exist');
});
it('should close modal when clicking outside (backdrop)', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('modal-backdrop').click({ force: true });
cy.getByTestId('flight-details-modal').should('not.exist');
});
});
describe('Modal Content - Flight Information Display', () => {
it('should display flight number in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-number').should('be.visible')
.should('contain', 'SU');
});
it('should display estimated arrival time in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-time').should('be.visible');
});
it('should display gate information in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-gate').should('be.visible')
.should('contain', 'Gate');
});
it('should display terminal information in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-terminal').should('be.visible')
.should('contain', 'Terminal');
});
it('should display flight status in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-status').should('be.visible');
});
it('should display aircraft type in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-aircraft').should('be.visible');
});
});
describe('Modal Navigation', () => {
it('should navigate to next flight using next button in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
const firstFlightNumber = cy.getByTestId('flight-details-number');
cy.getByTestId('modal-next-button').click();
cy.getByTestId('flight-details-number')
.should('not.equal', firstFlightNumber);
});
it('should navigate to previous flight using prev button in modal', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().then((flights) => {
if (flights.length > 1) {
cy.getByTestId('flight-result').eq(1).click();
cy.getByTestId('modal-prev-button').click();
cy.getByTestId('flight-details-modal').should('be.visible');
}
});
});
it('should disable prev button on first flight', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('modal-prev-button').should('be.disabled');
});
it('should disable next button on last flight', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFlightResults().then((flights) => {
cy.getByTestId('flight-result').eq(flights.length - 1).click();
cy.getByTestId('modal-next-button').should('be.disabled');
});
});
});
describe('Modal Display and Responsiveness', () => {
it('should center modal on screen', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('flight-details-modal').should('be.visible');
cy.getByTestId('flight-details-modal')
.should('have.css', 'position')
.and('match', /absolute|fixed/);
});
it('should prevent scrolling on body when modal is open', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.get('body').should('have.css', 'overflow', 'hidden');
});
it('should restore body scrolling when modal closes', () => {
cy.selectArrivalCity('Москва');
cy.getByTestId('arrival-date-input').clear().type(today).type('{enter}');
cy.getByTestId('search-button').click();
cy.wait('@getFlights');
cy.getFirstFlightResult().click();
cy.getByTestId('modal-close-button').click();
cy.get('body').should('not.have.css', 'overflow', 'hidden');
});
});
});
});
@@ -0,0 +1,245 @@
import { POPULAR_REQUESTS } from '../../support/fixtures';
describe('Popular Requests Widget', () => {
beforeEach(() => {
cy.intercept('GET', '**/api/popular-requests/**', { statusCode: 200, body: POPULAR_REQUESTS }).as('getPopularRequests');
cy.forbidGeolocation();
cy.visit('/');
});
describe('Widget Load Tests', () => {
it('Should render widget on initial page load', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-requests-widget').should('exist');
});
it('Should be visible in viewport', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-requests-widget').should('be.visible');
});
it('Should have correct styling and layout', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-requests-widget').should('have.css', 'display').and('not.equal', 'none');
});
it('Should have correct container dimensions', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-requests-widget').then(($widget) => {
expect($widget.width()).to.be.greaterThan(0);
expect($widget.height()).to.be.greaterThan(0);
});
});
it('Should display widget title/header correctly', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-requests-widget').within(() => {
cy.getByTestId('popular-requests-title').should('exist').and('be.visible');
});
});
});
describe('Display Tests', () => {
it('Should display all popular request items from API', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').should('have.length', POPULAR_REQUESTS.length);
});
it('Should display departure city in each item', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').each(($item, index) => {
cy.wrap($item).within(() => {
cy.getByTestId('popular-request-departure').should('contain', POPULAR_REQUESTS[index].departure);
});
});
});
it('Should display arrival city in each item', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').each(($item, index) => {
cy.wrap($item).within(() => {
cy.getByTestId('popular-request-arrival').should('contain', POPULAR_REQUESTS[index].arrival);
});
});
});
it('Should display flight count/frequency in each item', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').each(($item, index) => {
cy.wrap($item).within(() => {
cy.getByTestId('popular-request-frequency').should('exist').and('be.visible');
});
});
});
it('Should have clickable items', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().should('have.css', 'cursor').and('not.equal', 'default');
});
it('Should display items with proper styling (colors, spacing)', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().then(($item) => {
const styles = window.getComputedStyle($item[0]);
expect(styles.padding).to.not.be.empty;
expect(styles.margin).to.not.be.empty;
});
});
it('Should render all items with correct data from first request', () => {
cy.wait('@getPopularRequests');
const firstItem = POPULAR_REQUESTS[0];
cy.getByTestId('popular-request-item').first().within(() => {
cy.getByTestId('popular-request-departure').should('contain', firstItem.departure);
cy.getByTestId('popular-request-arrival').should('contain', firstItem.arrival);
cy.getByTestId('popular-request-departure-code').should('contain', firstItem.departureCode);
cy.getByTestId('popular-request-arrival-code').should('contain', firstItem.arrivalCode);
});
});
it('Should render all items with correct data from second request', () => {
cy.wait('@getPopularRequests');
const secondItem = POPULAR_REQUESTS[1];
cy.getByTestId('popular-request-item').eq(1).within(() => {
cy.getByTestId('popular-request-departure').should('contain', secondItem.departure);
cy.getByTestId('popular-request-arrival').should('contain', secondItem.arrival);
cy.getByTestId('popular-request-departure-code').should('contain', secondItem.departureCode);
cy.getByTestId('popular-request-arrival-code').should('contain', secondItem.arrivalCode);
});
});
it('Should render all items with correct data from third request', () => {
cy.wait('@getPopularRequests');
const thirdItem = POPULAR_REQUESTS[2];
cy.getByTestId('popular-request-item').eq(2).within(() => {
cy.getByTestId('popular-request-departure').should('contain', thirdItem.departure);
cy.getByTestId('popular-request-arrival').should('contain', thirdItem.arrival);
cy.getByTestId('popular-request-departure-code').should('contain', thirdItem.departureCode);
cy.getByTestId('popular-request-arrival-code').should('contain', thirdItem.arrivalCode);
});
});
it('Should display frequency/high indicator for first item', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().within(() => {
cy.getByTestId('popular-request-frequency').should('contain', POPULAR_REQUESTS[0].frequency);
});
});
});
describe('Navigation Tests', () => {
it('Should navigate to search page when clicking item', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', '/onlineboard/');
});
it('Should include departure city code in URL after click', () => {
cy.wait('@getPopularRequests');
const firstItem = POPULAR_REQUESTS[0];
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', firstItem.departureCode);
});
it('Should include arrival city code in URL after click', () => {
cy.wait('@getPopularRequests');
const firstItem = POPULAR_REQUESTS[0];
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', firstItem.arrivalCode);
});
it('Should navigate with different parameters for different items', () => {
cy.wait('@getPopularRequests');
const firstItem = POPULAR_REQUESTS[0];
const secondItem = POPULAR_REQUESTS[1];
cy.getByTestId('popular-request-item').first().click();
cy.url().then((firstUrl) => {
cy.visit('/');
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').eq(1).click();
cy.url().then((secondUrl) => {
expect(firstUrl).to.not.equal(secondUrl);
});
});
});
it('Should navigate to departure city page', () => {
cy.wait('@getPopularRequests');
const firstItem = POPULAR_REQUESTS[0];
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', 'departure');
});
it('Should navigate to correct date range', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().click();
cy.url().should('match', /\d{8}-\d{4}-\d{4}/);
});
it('Should preserve language on navigation', () => {
cy.visit('/en-us/');
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', '/en-us/');
});
it('Should make search page load correctly after navigation', () => {
cy.wait('@getPopularRequests');
cy.getByTestId('popular-request-item').first().click();
cy.getByTestId('board-search-result', { timeout: 10000 }).should('exist');
});
});
describe('API Fallback Tests', () => {
it('Should fall back to fixture data when API fails', () => {
// Intercept API to fail, but first reset and visit
cy.intercept('GET', '**/api/popular-requests/**', { statusCode: 500 }).as('failedRequest');
cy.visit('/');
cy.wait('@failedRequest');
// Widget should still be visible with fallback data
cy.getByTestId('popular-requests-widget').should('be.visible');
});
it('Should display fallback data correctly on API error', () => {
cy.intercept('GET', '**/api/popular-requests/**', { statusCode: 500 }).as('failedRequest');
cy.visit('/');
cy.wait('@failedRequest');
// Fallback data should still have items
cy.getByTestId('popular-request-item').should('have.length.greaterThan', 0);
});
it('Should allow navigation even with API fallback', () => {
cy.intercept('GET', '**/api/popular-requests/**', { statusCode: 500 }).as('failedRequest');
cy.visit('/');
cy.wait('@failedRequest');
cy.getByTestId('popular-request-item').first().click();
cy.url().should('include', '/onlineboard/');
});
it('Should handle network timeout gracefully', () => {
cy.intercept('GET', '**/api/popular-requests/**', (req) => {
req.destroy();
}).as('timedOutRequest');
cy.visit('/');
cy.wait('@timedOutRequest');
cy.getByTestId('popular-requests-widget').should('be.visible');
cy.getByTestId('popular-request-item').should('have.length.greaterThan', 0);
});
it('Should render widget without breaking layout on API error', () => {
cy.intercept('GET', '**/api/popular-requests/**', { statusCode: 500 }).as('failedRequest');
cy.visit('/');
cy.wait('@failedRequest');
cy.getByTestId('popular-requests-widget').then(($widget) => {
expect($widget.width()).to.be.greaterThan(0);
expect($widget.height()).to.be.greaterThan(0);
});
});
});
});
@@ -0,0 +1,402 @@
import * as moment from 'moment';
describe('Responsive Design & Mobile Tests', () => {
const today = moment().format('DD.MM.YYYY');
const testCity = 'Анапа';
const testCityCode = 'AAQ';
// Helper to check no horizontal scrolling
const checkNoHorizontalScroll = () => {
cy.get('body').then(($body) => {
const windowWidth = $body[0].ownerDocument.defaultView.innerWidth;
const scrollWidth = $body[0].scrollWidth;
expect(scrollWidth).to.equal(windowWidth);
});
};
// Helper to check touch target size (minimum 44x44px)
const checkTouchTargetSize = (selector: string) => {
cy.get(selector).should(($el) => {
const rect = $el[0].getBoundingClientRect();
expect(rect.width).to.be.at.least(44);
expect(rect.height).to.be.at.least(44);
});
};
// Helper to check element is not hidden
const checkElementVisible = (selector: string) => {
cy.get(selector).should('be.visible').should('not.have.css', 'overflow', 'hidden');
};
// Mobile Viewport Tests (375x667 - iPhone SE)
describe('Mobile Viewport (375x667 - iPhone SE)', () => {
beforeEach(() => {
cy.viewport('iphone-se2');
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.forbidGeolocation();
cy.visit('/');
});
it('Mobile: Text is readable and not overflowing in filter section', () => {
cy.getByTestId('filter-section').should('be.visible');
cy.getByTestId('filter-section').then(($section) => {
const text = $section.text();
expect(text.length).to.be.greaterThan(0);
expect($section[0].scrollWidth).to.equal($section[0].clientWidth);
});
});
it('Mobile: Search button has minimum touch target size (44x44px)', () => {
checkTouchTargetSize('[data-testid="arrival-search-button"]');
});
it('Mobile: City input field has proper touch target size', () => {
checkTouchTargetSize('[data-testid="city-autocomplete-input"]');
});
it('Mobile: Calendar input has sufficient touch target size', () => {
checkTouchTargetSize('[data-testid="calendar-input"]');
});
it('Mobile: No horizontal scrolling on page load', () => {
checkNoHorizontalScroll();
});
it('Mobile: No horizontal scrolling after opening accordion', () => {
cy.getByTestId('accordion').should('exist').click();
checkNoHorizontalScroll();
});
it('Mobile: Form inputs are not hidden behind keyboard simulation', () => {
cy.getByTestId('city-autocomplete-input').should('be.visible').should('not.have.css', 'display', 'none');
cy.getByTestId('calendar-input').should('be.visible').should('not.have.css', 'display', 'none');
});
it('Mobile: Filter labels are readable and properly spaced', () => {
cy.getByTestId('filter-label').should('be.visible').each(($el) => {
const fontSize = window.getComputedStyle($el[0]).fontSize;
expect(parseInt(fontSize)).to.be.at.least(14);
});
});
it('Mobile: Input fields have adequate padding for mobile interaction', () => {
cy.getByTestId('city-autocomplete-input').should(($el) => {
const padding = window.getComputedStyle($el[0]).padding;
expect(padding).to.not.equal('0px');
});
});
it('Mobile: Hamburger menu opens and closes correctly', () => {
cy.getByTestId('hamburger-menu').should('exist').click();
cy.getByTestId('mobile-nav').should('be.visible');
cy.getByTestId('hamburger-menu').click();
cy.getByTestId('mobile-nav').should('not.be.visible');
});
it('Mobile: Accordion sections collapse and expand on tap', () => {
cy.getByTestId('accordion').should('exist');
cy.getByTestId('accordion').click();
cy.getByTestId('accordion-content').should('be.visible');
cy.getByTestId('accordion').click();
cy.getByTestId('accordion-content').should('not.be.visible');
});
it('Mobile: Images scale correctly without distortion', () => {
cy.getByTestId('company-logo').should('be.visible').each(($img) => {
const width = $img[0].getBoundingClientRect().width;
const height = $img[0].getBoundingClientRect().height;
expect(width).to.be.greaterThan(0);
expect(height).to.be.greaterThan(0);
});
});
it('Mobile: Button text is visible and not cut off', () => {
cy.getByTestId('arrival-search-button').should('be.visible').should(($btn) => {
const text = $btn.text();
expect(text).to.have.length.greaterThan(0);
});
});
it('Mobile: No text overflow in flight results', () => {
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
cy.getByTestId('flight-result').first().then(($result) => {
expect($result[0].scrollWidth).to.equal($result[0].clientWidth);
});
});
});
it('Mobile: Touch targets for flight results are appropriately sized', () => {
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
checkTouchTargetSize('[data-testid="flight-result"]');
});
});
it('Mobile: Proper spacing between interactive elements', () => {
cy.getByTestId('filter-section').should(($section) => {
const buttons = $section.find('[data-testid="arrival-search-button"]');
expect(buttons.length).to.be.greaterThan(0);
});
});
});
// Tablet Viewport Tests (768x1024 - iPad 2)
describe('Tablet Viewport (768x1024 - iPad 2)', () => {
beforeEach(() => {
cy.viewport('ipad-2');
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.forbidGeolocation();
cy.visit('/');
});
it('Tablet: Layout is optimized and not stretched', () => {
cy.getByTestId('main-content').should('be.visible').then(($content) => {
const width = $content[0].getBoundingClientRect().width;
expect(width).to.be.lessThan(768);
expect(width).to.be.greaterThan(400);
});
});
it('Tablet: Layout is not too narrow', () => {
cy.getByTestId('filter-section').should('be.visible').then(($section) => {
const width = $section[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(500);
});
});
it('Tablet: Multi-column layout works correctly', () => {
cy.getByTestId('filter-row').should('be.visible');
cy.getByTestId('filter-row').then(($row) => {
const columns = $row.find('[data-testid*="filter-col"]');
expect(columns.length).to.be.greaterThan(0);
});
});
it('Tablet: Touch interactions work for tapping elements', () => {
cy.getByTestId('accordion').should('exist').trigger('touchstart').trigger('touchend');
cy.getByTestId('accordion-content').should('be.visible');
});
it('Tablet: Buttons are appropriately sized for tablet interaction', () => {
checkTouchTargetSize('[data-testid="arrival-search-button"]');
});
it('Tablet: Spacing between form elements is balanced', () => {
cy.getByTestId('filter-section').should(($section) => {
const marginBottom = window.getComputedStyle($section[0]).marginBottom;
expect(marginBottom).to.not.equal('0px');
});
});
it('Tablet: No layout breaking on tablet orientation', () => {
checkNoHorizontalScroll();
});
it('Tablet: Forms fit properly within viewport', () => {
cy.getByTestId('filter-section').should('be.visible').then(($form) => {
const viewportWidth = window.innerWidth;
const formWidth = $form[0].getBoundingClientRect().width;
expect(formWidth).to.be.lessThan(viewportWidth);
});
});
it('Tablet: Input fields display correctly with proper size', () => {
cy.getByTestId('city-autocomplete-input').should('be.visible').then(($input) => {
const height = $input[0].getBoundingClientRect().height;
expect(height).to.be.greaterThan(30);
});
});
it('Tablet: Swipe left gesture works on content', () => {
cy.getByTestId('main-content').swipeLeft();
});
it('Tablet: Swipe right gesture works on content', () => {
cy.getByTestId('main-content').swipeRight();
});
it('Tablet: No horizontal scrolling with all content visible', () => {
checkNoHorizontalScroll();
});
it('Tablet: Images scale appropriately for tablet display', () => {
cy.getByTestId('company-logo').should('be.visible').each(($img) => {
const width = $img[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(20);
expect(width).to.be.lessThan(150);
});
});
});
// Desktop Viewport Tests (1920x1080)
describe('Desktop Viewport (1920x1080)', () => {
beforeEach(() => {
cy.viewport(1920, 1080);
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.forbidGeolocation();
cy.visit('/');
});
it('Desktop: Layout scales correctly without overflow', () => {
cy.getByTestId('main-content').should('be.visible').then(($content) => {
expect($content[0].scrollWidth).to.equal($content[0].clientWidth);
});
});
it('Desktop: No horizontal scrolling on large viewport', () => {
checkNoHorizontalScroll();
});
it('Desktop: All content is accessible without zooming', () => {
cy.getByTestId('filter-section').should('be.visible');
cy.getByTestId('city-autocomplete-input').should('be.visible');
cy.getByTestId('calendar-input').should('be.visible');
cy.getByTestId('arrival-search-button').should('be.visible');
});
it('Desktop: Multi-column layout is fully utilized', () => {
cy.getByTestId('filter-row').should('be.visible').then(($row) => {
const width = $row[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(1000);
});
});
it('Desktop: Typography is appropriate for large screens', () => {
cy.getByTestId('filter-section').should(($section) => {
const fontSize = window.getComputedStyle($section[0]).fontSize;
expect(parseInt(fontSize)).to.be.at.least(14);
});
});
it('Desktop: Buttons are properly proportioned for large screen', () => {
cy.getByTestId('arrival-search-button').should('be.visible').then(($btn) => {
const width = $btn[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(80);
});
});
it('Desktop: Form elements are well-spaced on large viewport', () => {
cy.getByTestId('filter-section').should(($section) => {
const padding = window.getComputedStyle($section[0]).padding;
expect(padding).to.not.equal('0px');
});
});
it('Desktop: Hover effects are available on buttons', () => {
cy.getByTestId('arrival-search-button').should('be.visible');
// Hover effect test - verify element responds to hover state
cy.getByTestId('arrival-search-button').trigger('mouseenter');
});
it('Desktop: Accordion content displays correctly on large screen', () => {
cy.getByTestId('accordion').should('exist').click();
cy.getByTestId('accordion-content').should('be.visible').then(($content) => {
const width = $content[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(200);
});
});
it('Desktop: Images are properly scaled for desktop display', () => {
cy.getByTestId('company-logo').should('be.visible').each(($img) => {
const width = $img[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(40);
});
});
it('Desktop: Page layout remains optimal with full-width utilization', () => {
cy.viewport(1920, 1080);
cy.get('body').then(($body) => {
const viewportWidth = window.innerWidth;
expect(viewportWidth).to.equal(1920);
});
});
it('Desktop: All form inputs are visible and accessible', () => {
cy.getByTestId('filter-section').find('[data-testid="city-autocomplete-input"]').should('be.visible');
cy.getByTestId('filter-section').find('[data-testid="calendar-input"]').should('be.visible');
});
it('Desktop: Navigation elements are properly sized for mouse interaction', () => {
cy.getByTestId('hamburger-menu').should('exist').then(($menu) => {
const width = $menu[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(30);
});
});
it('Desktop: Content does not extend beyond safe viewport margins', () => {
cy.get('body').then(($body) => {
const bodyWidth = $body[0].getBoundingClientRect().width;
const viewportWidth = window.innerWidth;
expect(bodyWidth).to.be.lessThanOrEqual(viewportWidth);
});
});
it('Desktop: Text remains readable across large viewport', () => {
cy.getByTestId('filter-label').should('be.visible').each(($el) => {
const lineHeight = window.getComputedStyle($el[0]).lineHeight;
const fontSize = window.getComputedStyle($el[0]).fontSize;
expect(parseInt(lineHeight)).to.be.greaterThan(parseInt(fontSize));
});
});
it('Desktop: Flight search results display correctly on large viewport', () => {
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
cy.getByTestId('board-search-result').should('be.visible');
cy.getByTestId('flight-result').should('have.length.at.least', 1);
cy.getByTestId('flight-result').first().then(($result) => {
const width = $result[0].getBoundingClientRect().width;
expect(width).to.be.greaterThan(300);
});
});
});
});
// Cross-viewport Tests
describe('Cross-Viewport Responsive Tests', () => {
beforeEach(() => {
cy.intercept('GET', '**api/flights/v1.1/ru/board**').as('getFlights');
cy.forbidGeolocation();
});
it('Responsive: Search works consistently on mobile viewport', () => {
cy.viewport('iphone-se2');
cy.visit('/');
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
});
it('Responsive: Search works consistently on tablet viewport', () => {
cy.viewport('ipad-2');
cy.visit('/');
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
});
it('Responsive: Search works consistently on desktop viewport', () => {
cy.viewport(1920, 1080);
cy.visit('/');
cy.getByTestId('city-autocomplete-input').type(testCity);
cy.getByTestId('calendar-input').type(today).type('{enter}');
cy.getByTestId('arrival-search-button').click();
cy.wait('@getFlights').then(() => {
cy.getByTestId('flight-result').should('have.length.at.least', 1);
});
});
});
});
@@ -0,0 +1,640 @@
import * as moment from 'moment';
/**
* Mock schedule results for testing
*/
const MOCK_SCHEDULE_RESULTS = [
{
flightNumber: 'SU1001',
carrier: 'SU',
number: '1001',
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
departureTime: '09:00',
arrivalTime: '10:30',
duration: '1h 30m',
aircraft: 'A320',
price: 3500,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
},
{
flightNumber: 'SU1002',
carrier: 'SU',
number: '1002',
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
departureTime: '12:15',
arrivalTime: '13:45',
duration: '1h 30m',
aircraft: 'A330',
price: 4200,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
},
{
flightNumber: 'SU1003',
carrier: 'SU',
number: '1003',
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
departureTime: '15:30',
arrivalTime: '17:00',
duration: '1h 30m',
aircraft: 'B737',
price: 2800,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
},
{
flightNumber: 'SU1004',
carrier: 'SU',
number: '1004',
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
departureTime: '18:45',
arrivalTime: '20:15',
duration: '1h 30m',
aircraft: 'A320',
price: 3100,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
},
{
flightNumber: 'SU1005',
carrier: 'SU',
number: '1005',
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
departureTime: '21:00',
arrivalTime: '22:30',
duration: '1h 30m',
aircraft: 'A320',
price: 3000,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
},
];
const MOCK_FLIGHT_DETAILS = {
flightNumber: 'SU1001',
carrier: 'SU',
number: '1001',
departure: 'Москва',
departureCode: 'MOW',
departureTime: '09:00',
departureTerminal: 'A',
departureGate: '5',
departureCheckIn: '07:00-08:45',
arrival: 'Санкт-Петербург',
arrivalCode: 'LED',
arrivalTime: '10:30',
arrivalTerminal: 'B',
arrivalGate: '12',
duration: '1h 30m',
aircraft: 'A320',
aircraftCode: 'A20',
boardingTime: '08:30',
price: 3500,
stops: 0,
operating: 'SU',
flightStatus: 'On Schedule',
};
describe('Расписание: Комплексные тесты', () => {
const route = {
departureCity: {
name: 'Москва',
code: 'MOW',
latitude: 55.7558,
longitude: 37.62,
},
arrivalCity: {
name: 'Санкт-Петербург',
code: 'LED',
latitude: 59.9311,
longitude: 30.3609,
},
alternateArrivalCity: {
name: 'Сочи',
code: 'AER',
latitude: 43.4391,
longitude: 39.9566,
},
};
beforeEach(() => {
cy.intercept('GET', '**/api/flights/1/ru/schedule**', MOCK_SCHEDULE_RESULTS).as('getSchedule');
cy.intercept('GET', '**/api/flights/1/ru/schedule/details**', MOCK_FLIGHT_DETAILS).as('getFlightDetails');
cy.intercept('GET', '**/api/cities/**', {
statusCode: 200,
body: [route.departureCity, route.arrivalCity, route.alternateArrivalCity],
}).as('getCities');
cy.mockGeolocation(route.departureCity);
cy.visit('/ru-ru/schedule');
});
// ============================================================
// SEARCH PAGE TESTS (~25 tests)
// ============================================================
describe('Search Page - Origin Autocomplete', () => {
it('Should allow manual entry of origin city', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.getByTestId('schedule-departure-city-input').getByTestId('city-code').should('contain', 'MOW');
});
it('Should filter origin cities as user types', () => {
cy.getByTestId('schedule-departure-city-input').type('М');
cy.getByTestId('city-dropdown-option').should('have.length.at.least', 1);
});
it('Should select origin city from dropdown', () => {
cy.getByTestId('schedule-departure-city-input').type('Мо');
cy.getByTestId('city-dropdown-option').first().click();
cy.getByTestId('schedule-departure-city-input').getByTestId('city-code').should('contain', 'MOW');
});
it('Should clear origin city selection', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва');
cy.getByTestId('schedule-departure-city-input').parent().find('[class*="clear"]').click({ force: true });
cy.getByTestId('schedule-departure-city-input').should('have.value', '');
});
it('Should validate that origin city is required', () => {
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург');
cy.getByTestId('schedule-search-button').click();
cy.getByTestId('validation-error').should('be.visible');
});
it('Should display origin city code after selection', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.getByTestId('city-code').contains('MOW').should('be.visible');
});
it('Should handle rapid typing in origin field', () => {
cy.getByTestId('schedule-departure-city-input').type('М', { delay: 10 }).type('о', { delay: 10 });
cy.getByTestId('city-dropdown-option').should('have.length.at.least', 1);
});
it('Should preserve origin city when navigating to details', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.getByTestId('schedule-search-result').first().click();
cy.url().should('include', 'details');
});
});
describe('Search Page - Destination Autocomplete', () => {
it('Should allow manual entry of destination city', () => {
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-arrival-city-input').getByTestId('city-code').should('contain', 'LED');
});
it('Should filter destination cities as user types', () => {
cy.getByTestId('schedule-arrival-city-input').type('С');
cy.getByTestId('city-dropdown-option').should('have.length.at.least', 1);
});
it('Should select destination city from dropdown', () => {
cy.getByTestId('schedule-arrival-city-input').type('Са');
cy.getByTestId('city-dropdown-option').first().click();
cy.getByTestId('schedule-arrival-city-input').getByTestId('city-code').should('be.visible');
});
it('Should clear destination city selection', () => {
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург');
cy.getByTestId('schedule-arrival-city-input').parent().find('[class*="clear"]').click({ force: true });
cy.getByTestId('schedule-arrival-city-input').should('have.value', '');
});
it('Should validate that destination city is required', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва');
cy.getByTestId('schedule-search-button').click();
cy.getByTestId('validation-error').should('be.visible');
});
it('Should prevent same city for origin and destination', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Москва').type('{enter}');
cy.getByTestId('validation-error').should('contain', 'одинаков');
});
it('Should display destination city code after selection', () => {
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('city-code').contains('LED').should('be.visible');
});
});
describe('Search Page - Date Range Picker', () => {
it('Should set start date using date picker', () => {
const startDate = moment().format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').first().clear().type(startDate).type('{enter}');
cy.getByTestId('schedule-calendar').first().should('have.value', startDate);
});
it('Should set end date using date picker', () => {
const endDate = moment().add(7, 'days').format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').last().clear().type(endDate).type('{enter}');
cy.getByTestId('schedule-calendar').last().should('have.value', endDate);
});
it('Should allow single-day range', () => {
const singleDate = moment().format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').first().clear().type(singleDate).type('{enter}');
cy.getByTestId('schedule-calendar').last().clear().type(singleDate).type('{enter}');
cy.getByTestId('schedule-calendar').first().should('have.value', singleDate);
cy.getByTestId('schedule-calendar').last().should('have.value', singleDate);
});
it('Should allow full range selection (7 days)', () => {
const startDate = moment().format('DD.MM.YYYY');
const endDate = moment().add(7, 'days').format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').first().clear().type(startDate).type('{enter}');
cy.getByTestId('schedule-calendar').last().clear().type(endDate).type('{enter}');
cy.getByTestId('schedule-calendar').first().should('have.value', startDate);
cy.getByTestId('schedule-calendar').last().should('have.value', endDate);
});
it('Should reject end date before start date', () => {
const endDate = moment().format('DD.MM.YYYY');
const startDate = moment().add(7, 'days').format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').first().clear().type(startDate).type('{enter}');
cy.getByTestId('schedule-calendar').last().clear().type(endDate).type('{enter}');
cy.getByTestId('validation-error').should('be.visible');
});
it('Should use today as default start date', () => {
cy.getByTestId('schedule-calendar').first().should('have.value', moment().format('DD.MM.YYYY'));
});
it('Should prevent date in the past', () => {
const pastDate = moment().subtract(1, 'days').format('DD.MM.YYYY');
cy.getByTestId('schedule-calendar').first().clear().type(pastDate).type('{enter}');
cy.getByTestId('validation-error').should('be.visible');
});
it('Should allow date selection via calendar popup', () => {
cy.getByTestId('schedule-calendar').first().click();
cy.get('[class*="calendar"]').find('[class*="day"]').contains(moment().date().toString()).click({ force: true });
cy.getByTestId('schedule-calendar').first().should('have.value', moment().format('DD.MM.YYYY'));
});
});
describe('Search Page - Form Submission', () => {
it('Should submit valid search form', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.getByTestId('schedule-search-results').should('be.visible');
});
it('Should show loading indicator during search', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.getByTestId('loader').should('be.visible');
});
it('Should display error on network failure', () => {
cy.intercept('GET', '**/api/flights/1/ru/schedule**', { statusCode: 500 }).as('getScheduleError');
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getScheduleError');
cy.getByTestId('error-message').should('be.visible');
});
it('Should handle empty search results', () => {
cy.intercept('GET', '**/api/flights/1/ru/schedule**', []).as('getScheduleEmpty');
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getScheduleEmpty');
cy.getByTestId('empty-results-message').should('be.visible');
});
it('Should not submit with missing origin city', () => {
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.get('@getSchedule.all').should('have.length', 0);
});
it('Should not submit with missing destination city', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.get('@getSchedule.all').should('have.length', 0);
});
it('Should display correct URL after search', () => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.url().should('include', 'schedule');
});
it('Should enable search button only when form is valid', () => {
cy.getByTestId('schedule-search-button').should('be.disabled');
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-search-button').should('be.disabled');
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-search-button').should('be.enabled');
});
});
// ============================================================
// FLIGHT DETAILS PAGE TESTS (~20 tests)
// ============================================================
describe('Flight Details Page - Flight Information', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.getByTestId('schedule-search-result').first().click();
cy.wait('@getFlightDetails');
});
it('Should display flight number', () => {
cy.getByTestId('flight-details-number').should('contain', 'SU');
});
it('Should display departure information', () => {
cy.getByTestId('flight-departure-time').should('be.visible');
cy.getByTestId('flight-departure-city').should('contain', 'Москва');
});
it('Should display arrival information', () => {
cy.getByTestId('flight-arrival-time').should('be.visible');
cy.getByTestId('flight-arrival-city').should('contain', 'Санкт-Петербург');
});
it('Should display flight duration', () => {
cy.getByTestId('flight-duration').should('contain', 'h');
});
it('Should display aircraft type', () => {
cy.getByTestId('flight-aircraft').should('contain', 'A320');
});
it('Should display airline logo', () => {
cy.getByTestId('flight-company-logo').should('be.visible');
});
it('Should display price information', () => {
cy.getByTestId('flight-price').should('be.visible').should('contain', '3500');
});
it('Should display number of stops', () => {
cy.getByTestId('flight-stops').should('contain', '0');
});
});
describe('Flight Details Page - Timing Details', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.getByTestId('schedule-search-result').first().click();
cy.wait('@getFlightDetails');
});
it('Should display departure gate', () => {
cy.getByTestId('flight-departure-gate').should('contain', '5');
});
it('Should display departure terminal', () => {
cy.getByTestId('flight-departure-terminal').should('contain', 'A');
});
it('Should display check-in time range', () => {
cy.getByTestId('flight-check-in-time').should('contain', '07:00');
});
it('Should display boarding time', () => {
cy.getByTestId('flight-boarding-time').should('contain', '08:30');
});
it('Should display arrival gate', () => {
cy.getByTestId('flight-arrival-gate').should('contain', '12');
});
it('Should display arrival terminal', () => {
cy.getByTestId('flight-arrival-terminal').should('contain', 'B');
});
});
describe('Flight Details Page - Navigation', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
cy.getByTestId('schedule-search-result').first().click();
cy.wait('@getFlightDetails');
});
it('Should navigate to next flight', () => {
cy.getByTestId('next-flight-button').click();
cy.wait('@getFlightDetails');
cy.getByTestId('flight-details-number').should('be.visible');
});
it('Should navigate to previous flight', () => {
cy.getByTestId('next-flight-button').click();
cy.wait('@getFlightDetails');
cy.getByTestId('prev-flight-button').click();
cy.wait('@getFlightDetails');
cy.getByTestId('flight-details-number').should('be.visible');
});
it('Should return to search results', () => {
cy.getByTestId('back-to-search-button').click();
cy.url().should('include', 'schedule');
cy.getByTestId('schedule-search-results').should('be.visible');
});
it('Should remember search filters when returning', () => {
cy.getByTestId('back-to-search-button').click();
cy.getByTestId('schedule-departure-city-input').getByTestId('city-code').should('contain', 'MOW');
cy.getByTestId('schedule-arrival-city-input').getByTestId('city-code').should('contain', 'LED');
});
it('Should disable previous button on first flight', () => {
cy.getByTestId('prev-flight-button').should('be.disabled');
});
});
// ============================================================
// FILTERS & SORTING TESTS (~15 tests)
// ============================================================
describe('Search Results - Filters', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
});
it('Should toggle time range filter', () => {
cy.getByTestId('time-filter-toggle').click();
cy.getByTestId('time-filter-panel').should('be.visible');
});
it('Should set minimum departure time', () => {
cy.getByTestId('time-filter-toggle').click();
cy.getByTestId('time-filter-min-slider').invoke('val', '09').trigger('input');
cy.getByTestId('schedule-search-result').each(($flight) => {
cy.wrap($flight).getByTestId('flight-departure-time').should('be.visible');
});
});
it('Should set maximum departure time', () => {
cy.getByTestId('time-filter-toggle').click();
cy.getByTestId('time-filter-max-slider').invoke('val', '18').trigger('input');
cy.getByTestId('schedule-search-result').each(($flight) => {
cy.wrap($flight).getByTestId('flight-departure-time').should('be.visible');
});
});
it('Should toggle airline filter', () => {
cy.getByTestId('airline-filter-toggle').click();
cy.getByTestId('airline-filter-panel').should('be.visible');
});
it('Should select single airline', () => {
cy.getByTestId('airline-filter-toggle').click();
cy.getByTestId('airline-filter-option').first().click();
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
it('Should deselect airline', () => {
cy.getByTestId('airline-filter-toggle').click();
cy.getByTestId('airline-filter-option').first().click();
cy.getByTestId('airline-filter-option').first().click();
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
it('Should toggle price range filter', () => {
cy.getByTestId('price-filter-toggle').click();
cy.getByTestId('price-filter-panel').should('be.visible');
});
it('Should set minimum price', () => {
cy.getByTestId('price-filter-toggle').click();
cy.getByTestId('price-filter-min-input').clear().type('3000');
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
it('Should set maximum price', () => {
cy.getByTestId('price-filter-toggle').click();
cy.getByTestId('price-filter-max-input').clear().type('4000');
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
it('Should clear all filters', () => {
cy.getByTestId('clear-filters-button').click();
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
});
describe('Search Results - Sorting', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
});
it('Should sort by departure time ascending', () => {
cy.getByTestId('sort-dropdown').click();
cy.getByTestId('sort-option-departure-asc').click();
cy.getByTestId('schedule-search-result').first().getByTestId('flight-departure-time').should('contain', '09:00');
});
it('Should sort by departure time descending', () => {
cy.getByTestId('sort-dropdown').click();
cy.getByTestId('sort-option-departure-desc').click();
cy.getByTestId('schedule-search-result').first().getByTestId('flight-departure-time').should('contain', '21:00');
});
it('Should sort by flight duration', () => {
cy.getByTestId('sort-dropdown').click();
cy.getByTestId('sort-option-duration').click();
cy.getByTestId('schedule-search-result').should('have.length.at.least', 1);
});
it('Should sort by price ascending', () => {
cy.getByTestId('sort-dropdown').click();
cy.getByTestId('sort-option-price-asc').click();
cy.getByTestId('schedule-search-result').first().getByTestId('flight-price').should('contain', '2800');
});
it('Should sort by price descending', () => {
cy.getByTestId('sort-dropdown').click();
cy.getByTestId('sort-option-price-desc').click();
cy.getByTestId('schedule-search-result').first().getByTestId('flight-price').should('contain', '4200');
});
});
describe('Search Results - Result Display', () => {
beforeEach(() => {
cy.getByTestId('schedule-departure-city-input').type('Москва').type('{enter}');
cy.wait(200);
cy.getByTestId('schedule-arrival-city-input').type('Санкт-Петербург').type('{enter}');
cy.getByTestId('schedule-search-button').click();
cy.wait('@getSchedule');
});
it('Should display multiple flight results', () => {
cy.getByTestId('schedule-search-result').should('have.length', 5);
});
it('Should highlight flight on hover', () => {
cy.getByTestId('schedule-search-result').first().trigger('mouseover');
cy.getByTestId('schedule-search-result').first().should('have.class', 'highlighted');
});
it('Should show flight details on click', () => {
cy.getByTestId('schedule-search-result').first().click();
cy.wait('@getFlightDetails');
cy.getByTestId('flight-details-number').should('be.visible');
});
});
});
+74 -43
View File
@@ -1,48 +1,8 @@
/// <reference types="." />
// ***********************************************
// This example namespace declaration will help
// with Intellisense and code completion in your
// IDE or Text Editor.
// ***********************************************
// declare namespace Cypress {
// interface Chainable<Subject = any> {
// customCommand(param: any): typeof customCommand;
// }
// }
//
// function customCommand(param: any): void {
// console.warn(param);
// }
//
// NOTE: You can use it like so:
// Cypress.Commands.add('customCommand', customCommand);
//
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
/**
* Custom Cypress commands for Aeroflot Flights Web testing
*/
Cypress.Commands.add('getByTestId', (id: string, timeout = 8000) => {
return cy.get(`[data-testid="${id}"]`, { timeout });
@@ -71,3 +31,74 @@ Cypress.Commands.add('forbidGeolocation', () => {
cy.stub(window.navigator.geolocation, 'watchPosition').callsFake(callback);
});
});
// Select arrival city by name
Cypress.Commands.add('selectArrivalCity', (cityName: string) => {
cy.getByTestId('city-autocomplete-input-arrival').clear().type(cityName);
cy.getByTestId('city-dropdown-option').contains(cityName).click();
});
// Select departure city by name
Cypress.Commands.add('selectDepartureCity', (cityName: string) => {
cy.getByTestId('city-autocomplete-input-departure').clear().type(cityName);
cy.getByTestId('city-dropdown-option').contains(cityName).click();
});
// Set arrival date using date picker
Cypress.Commands.add('setArrivalDate', (date: string) => {
cy.getByTestId('arrival-date-input').clear().type(date).type('{enter}');
});
// Set departure date using date picker
Cypress.Commands.add('setDepartureDate', (date: string) => {
cy.getByTestId('departure-date-input').clear().type(date).type('{enter}');
});
// Click search button
Cypress.Commands.add('clickSearchButton', () => {
cy.getByTestId('search-button').click();
});
// Get all flight results
Cypress.Commands.add('getFlightResults', () => {
return cy.getByTestId('flight-result');
});
// Get first flight result
Cypress.Commands.add('getFirstFlightResult', () => {
return cy.getByTestId('flight-result').first();
});
// Assert validation error is displayed
Cypress.Commands.add('shouldShowValidationError', (message: string) => {
cy.getByTestId('validation-error').should('contain', message);
});
// Select language by code
Cypress.Commands.add('selectLanguage', (langCode: string) => {
cy.getByTestId('language-selector').click();
cy.getByTestId(`language-option-${langCode}`).click();
});
// Get current language
Cypress.Commands.add('getCurrentLanguage', () => {
return cy.getByTestId('language-selector').invoke('text');
});
// Swipe right (for mobile navigation)
Cypress.Commands.add('swipeRight', { prevSubject: 'element' }, (subject) => {
cy.wrap(subject)
.trigger('touchstart', { which: 1, pageX: 0, pageY: 0 })
.trigger('touchmove', { which: 1, pageX: 100, pageY: 0 })
.trigger('touchend', { which: 1, pageX: 100, pageY: 0 });
return cy.wrap(subject);
});
// Swipe left (for mobile navigation)
Cypress.Commands.add('swipeLeft', { prevSubject: 'element' }, (subject) => {
cy.wrap(subject)
.trigger('touchstart', { which: 1, pageX: 100, pageY: 0 })
.trigger('touchmove', { which: 1, pageX: 0, pageY: 0 })
.trigger('touchend', { which: 1, pageX: 0, pageY: 0 });
return cy.wrap(subject);
});
+202
View File
@@ -0,0 +1,202 @@
/**
* Cypress test fixtures for Aeroflot Flights Web application
*/
export const CITIES = {
arrival: [
{
name: 'Москва',
code: 'MOW',
latitude: 55.7558,
longitude: 37.6173,
},
{
name: 'Санкт-Петербург',
code: 'LED',
latitude: 59.8011,
longitude: 30.2642,
},
{
name: 'Анапа',
code: 'AAQ',
latitude: 44.8972,
longitude: 37.3426,
},
{
name: 'Екатеринбург',
code: 'SVX',
latitude: 56.7365,
longitude: 60.8025,
},
{
name: 'Новосибирск',
code: 'OVB',
latitude: 55.0077,
longitude: 82.9484,
},
],
departure: [
{
name: 'Москва',
code: 'MOW',
latitude: 55.7558,
longitude: 37.6173,
},
{
name: 'Сочи',
code: 'AER',
latitude: 43.4391,
longitude: 39.9566,
},
{
name: 'Казань',
code: 'KZN',
latitude: 55.6084,
longitude: 49.2808,
},
],
};
export const MOCK_FLIGHTS_ARRIVAL = [
{
carrier: 'SU',
number: '001',
aircraft: 'A320',
estimatedTime: '10:15',
actualTime: '10:20',
status: 'Landed',
terminal: 'A',
gate: '12',
checkIn: '09:15-10:15',
},
{
carrier: 'SU',
number: '002',
aircraft: 'A330',
estimatedTime: '14:30',
actualTime: '14:28',
status: 'Landed',
terminal: 'B',
gate: '24',
checkIn: '13:30-14:30',
},
{
carrier: 'SU',
number: '003',
aircraft: 'B737',
estimatedTime: '22:45',
actualTime: null,
status: 'On Schedule',
terminal: 'A',
gate: '15',
checkIn: '21:45-22:45',
},
];
export const MOCK_FLIGHTS_DEPARTURE = [
{
carrier: 'SU',
number: '101',
aircraft: 'A320',
estimatedTime: '08:00',
actualTime: '08:05',
status: 'Departed',
terminal: 'A',
gate: '5',
checkIn: '06:00-07:45',
},
{
carrier: 'SU',
number: '102',
aircraft: 'A330',
estimatedTime: '12:30',
actualTime: null,
status: 'Boarding',
terminal: 'B',
gate: '18',
checkIn: '10:30-12:15',
},
];
export const POPULAR_REQUESTS = [
{
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Анапа',
arrivalCode: 'AAQ',
frequency: 'High',
},
{
departure: 'Москва',
departureCode: 'MOW',
arrival: 'Сочи',
arrivalCode: 'AER',
frequency: 'High',
},
{
departure: 'Санкт-Петербург',
departureCode: 'LED',
arrival: 'Москва',
arrivalCode: 'MOW',
frequency: 'Medium',
},
];
export const LANGUAGES = [
{
code: 'ru',
name: 'Русский',
nativeName: 'Русский',
},
{
code: 'en',
name: 'English',
nativeName: 'English',
},
{
code: 'es',
name: 'Spanish',
nativeName: 'Español',
},
{
code: 'fr',
name: 'French',
nativeName: 'Français',
},
{
code: 'it',
name: 'Italian',
nativeName: 'Italiano',
},
{
code: 'ja',
name: 'Japanese',
nativeName: '日本語',
},
{
code: 'ko',
name: 'Korean',
nativeName: '한국어',
},
{
code: 'zh',
name: 'Chinese',
nativeName: '中文',
},
{
code: 'de',
name: 'German',
nativeName: 'Deutsch',
},
];
export const TEST_USERS = {
guest: {
username: null,
displayName: 'Guest',
},
authenticated: {
username: 'testuser@example.com',
displayName: 'Test User',
},
};
+13 -1
View File
@@ -3,6 +3,18 @@ declare namespace Cypress {
interface Chainable {
getByTestId(id: string, timeout?: number): Chainable;
mockGeolocation({ latitude, longitude }): void;
forbidGeolocation();
forbidGeolocation(): void;
selectArrivalCity(cityName: string): Chainable;
selectDepartureCity(cityName: string): Chainable;
setArrivalDate(date: string): Chainable;
setDepartureDate(date: string): Chainable;
clickSearchButton(): Chainable;
getFlightResults(): Chainable;
getFirstFlightResult(): Chainable;
shouldShowValidationError(message: string): Chainable;
selectLanguage(langCode: string): Chainable;
getCurrentLanguage(): Chainable;
swipeRight(): Chainable;
swipeLeft(): Chainable;
}
}
+20 -10
View File
@@ -1,17 +1,27 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
// This support file is processed and loaded automatically
// before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// When a command from ./commands is ready to use, import with `import './commands'` syntax
import './commands';
// Clear application state before each test
beforeEach(() => {
cy.window().then((win) => {
// Clear localStorage
win.localStorage.clear();
// Clear sessionStorage
win.sessionStorage.clear();
// Clear IndexedDB if available
if (win.indexedDB && typeof win.indexedDB.databases === 'function') {
win.indexedDB.databases().then((dbs: any[]) => {
dbs.forEach(db => {
win.indexedDB.deleteDatabase(db.name);
});
});
}
});
});
+33301 -21446
View File
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -15,11 +15,16 @@
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"test": "ng test --code-coverage",
"test:ci": "ng test --watch=false --reporters=teamcity",
"test:e2e": "cypress run",
"pretty": "prettier --write \"./**/*.{ts,html}\"",
"analyze": "webpack-bundle-analyzer dist/stats.json",
"docs:json": "compodoc -p ./tsconfig.json -e json -d .",
"storybook": "npm run docs:json && start-storybook -p 6006",
"build-storybook": "npm run docs:json && build-storybook"
"build-storybook": "npm run docs:json && build-storybook",
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"cypress:run:all": "cypress run --spec 'cypress/integration/**/*.ts'",
"cypress:run:feature": "cypress run --spec 'cypress/integration/**/*.ts' --headed"
},
"dependencies": {
"@angular/animations": "~12.2.13",
@@ -63,11 +68,12 @@
"@storybook/manager-webpack5": "^6.4.20",
"@storybook/testing-library": "0.0.9",
"@types/jasmine": "^3.10.2",
"@types/leaflet": "^1.7.1",
"@types/node": "^12.11.1",
"@types/leaflet": "^1.7.11",
"@types/node": "^12.20.55",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"babel-loader": "^8.2.4",
"cypress": "^13.17.0",
"eslint": "^8.2.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-storybook": "^0.5.7",
@@ -82,6 +88,7 @@
"prettier": "2.4.1",
"start-server-and-test": "~1.14.0",
"timezone-mock": "^1.3.2",
"ts-loader": "^9.5.7",
"typescript": "~4.3.5",
"webpack-bundle-analyzer": "^4.5.0"
},
@@ -93,9 +100,5 @@
"Android > 4.3",
"iOS > 9",
"Edge > 13"
],
"main": ".eslintrc.js",
"keywords": [],
"license": "ISC",
"description": ""
]
}
@@ -4,7 +4,7 @@
<label class="city-autocomplete__label" data-testid="city-code">{{ city?.code }}</label>
</div>
<tooltip *ngIf="error">
<tooltip *ngIf="error" data-testid="validation-error">
{{ error | translate }}
</tooltip>
@@ -1,5 +1,5 @@
<div class="map-wrapper">
<div id="map" class="map"></div>
<div class="map-wrapper" data-testid="flights-map-container">
<div id="map" class="map" data-testid="leaflet-map"></div>
<loader-sheet *ngIf="isLoading"></loader-sheet>
<no-directions-sheet
*ngIf="isNoDirections && !isLoading"
@@ -2,7 +2,7 @@
<p-accordion expandIcon="" collapseIcon="" [activeIndex]="0">
<p-accordionTab [selected]="true" [disabled]="true">
<div class="flights-map-filter-content">
<div class="flights-map-filter-header">
<h3>{{ 'FLIGHTS-MAP.ROUTE' | translate }}</h3>
</div>
@@ -12,9 +12,9 @@
label="SHARED.DEPARTURE_CITY"
[(ngModel)]="departure"
[placeholder]="departurePlaceholder"
data-testid="route-departure-city-input">
data-testid="destination-search-input">
</city-autocomplete>
<div class="change-container">
<button
class="button-change"
@@ -31,7 +31,6 @@
label="SHARED.ARRIVAL_CITY"
[(ngModel)]="arrival"
[placeholder]="arrivalPlaceholder"
data-testid="route-arrival-city-input"
></city-autocomplete>
</div>
@@ -3,7 +3,7 @@
<label class="label--filter">{{
'SHARED.FLIGHT_NUMBER' | translate
}}</label>
<tooltip *ngIf="validationService.flightNumberError">{{
<tooltip *ngIf="validationService.flightNumberError" data-testid="validation-error">{{
validationService.flightNumberError | translate
}}</tooltip>
@@ -26,14 +26,14 @@
placeholder="{{
'SHARED.FLIGHT_NUMBER_PLACEHOLDER' | translate
}}"
data-testid="flight-number-input"
data-testid="flight-number-filter"
/>
<button
pButton
label=" "
class="button-clear"
(click)="clearInput()"
data-testid="flight-number-clear-button"
data-testid="flight-number-clear"
></button>
</div>
</div>
@@ -4,7 +4,7 @@
[(ngModel)]="departure"
[(error)]="validationService.departureError"
[placeholder]="departurePlaceholder"
data-testid="route-departure-city-input"
data-testid="departure-city-input"
></city-autocomplete>
<div class="change-container">
@@ -24,7 +24,7 @@
[(ngModel)]="arrival"
[(error)]="validationService.arrivalError"
[placeholder]="arrivalPlaceholder"
data-testid="route-arrival-city-input"
data-testid="arrival-city-input"
></city-autocomplete>
<calendar-input
@@ -34,7 +34,7 @@
[minDate]="minDate"
[maxDate]="maxDate"
[disabledDates]="disabledDates"
data-testid="route-calendar-input"
data-testid="departure-date-input"
>
</calendar-input>
</div>
@@ -43,6 +43,7 @@
[fullView]="false"
[(ngModel)]="timeRange"
label="{{ 'SHARED.FLIGHT_TIME' | translate }}"
data-testid="time-range-slider"
>
</time-selector>
@@ -53,6 +54,6 @@
type="button"
label="{{ 'SHARED.SEARCH' | translate }}"
(click)="search()"
data-testid="route-search-button"
data-testid="search-button"
></button>
</div>
@@ -1,4 +1,4 @@
<div *ngIf="flightLegacy">
<div *ngIf="flightLegacy" data-testid="flight-details-modal">
<page-layout scrollUp [withScrollUp]="false">
<ng-container title>
<ng-content select="[title]"></ng-content>
@@ -7,6 +7,7 @@
header-left
class="p-print-none"
[viewType]="ViewType.Onlineboard"
data-testid="modal-close-button"
></details-back>
<online-board-flights-mini-list
content-left
@@ -7,6 +7,7 @@
[searchDate]="searchDate"
(open)="handleOpenEvent($event)"
(dateChange)="handleDateChange($event)"
data-testid="flight-details-page"
>
<online-board-flight-details-title
title
@@ -1,4 +1,4 @@
{{ 'BOARD.DEPARTURE' | translate }}:
<request-info (click)="onRequestInfoClick()">{{
<request-info (click)="onRequestInfoClick()" data-testid="popular-request-departure">{{
request.departure | cityName
}}</request-info>
@@ -3,11 +3,13 @@
*ngSwitchCase="RequestMode.ARRIVAL"
[request]="$any(request)"
(onClick)="onRequestClick($event)"
data-testid="popular-request-arrival"
></arrival-request>
<departure-request
*ngSwitchCase="RequestMode.DEPARTURE"
[request]="$any(request)"
(onClick)="onRequestClick($event)"
data-testid="popular-request-departure"
></departure-request>
<flight-number-request
*ngSwitchCase="RequestMode.FLIGHT_NUMBER"
@@ -1,4 +1,4 @@
<div class="popular-requests">
<div class="popular-requests" data-testid="popular-requests-widget">
<h3 class="popular-requests__title">
{{ 'BOARD.POPULAR-CHAPTERS' | translate }}
</h3>
@@ -7,23 +7,27 @@
[request]="requests[0]"
(onClick)="handleRequestClick($event)"
class="popular-requests__item"
data-testid="popular-request-item"
></popular-request>
<popular-request
*ngIf="requests[1]"
[request]="requests[1]"
(onClick)="handleRequestClick($event)"
class="popular-requests__item"
data-testid="popular-request-item"
></popular-request>
<popular-request
*ngIf="requests[2]"
[request]="requests[2]"
(onClick)="handleRequestClick($event)"
class="popular-requests__item"
data-testid="popular-request-item"
></popular-request>
<popular-request
*ngIf="requests[3]"
[request]="requests[3]"
(onClick)="handleRequestClick($event)"
class="popular-requests__item"
data-testid="popular-request-item"
></popular-request>
</div>
@@ -10,7 +10,7 @@
[(ngModel)]="departure"
[(error)]="validationService.departureError"
placeholder="SHARED.CITY_PLACEHOLDER"
data-testid="schedule-departure-city-input"
data-testid="origin-input"
></city-autocomplete>
<div class="change-container">
@@ -34,7 +34,7 @@
[(ngModel)]="arrival"
[(error)]="validationService.arrivalError"
placeholder="SHARED.CITY_PLACEHOLDER"
data-testid="schedule-arrival-city-input"
data-testid="destination-input"
>
</city-autocomplete>
</div>
@@ -51,7 +51,7 @@
[minDate]="settings.scheduleMinDate"
[maxDate]="maxScheduleDate"
[disabledDates]="disabledDates"
data-testid="schedule-calendar"
data-testid="date-range-picker"
>
</calendar-input-week>
@@ -59,6 +59,7 @@
[fullView]="false"
[(ngModel)]="timeRange"
label="{{ 'SHARED.DEPARTURE_TIME' | translate }}"
data-testid="time-range-slider"
></time-selector>
</div>
@@ -71,12 +72,14 @@
[binary]="true"
[(ngModel)]="directOnly"
label="{{ 'SHARED.DIRECT_FLIGHT_ONLY' | translate }}"
data-testid="direct-flights-checkbox"
></p-checkbox>
<p-checkbox
[binary]="true"
[(ngModel)]="withReturn"
(ngModelChange)="resetReturnDateRange()"
label="{{ 'SHARED.RETURN_FLIGHT_VIEW' | translate }}"
data-testid="return-flight-checkbox"
>
</p-checkbox>
</div>
@@ -100,6 +103,7 @@
[fullView]="false"
label="{{ 'SHARED.RETURN_FLIGHT_TIME' | translate }}"
[(ngModel)]="returnTimeRange"
data-testid="return-time-range-slider"
>
</time-selector>
</div>
@@ -8,6 +8,7 @@
[detailsLoading]="dataSource.detailsLoading"
(toFlightDetails)="handleRedirectToFlightDetails($event)"
(toScheduleDate)="handleRedirectToScheduleDate($event)"
data-testid="flight-details-page"
>
<schedule-flight-details-title
[flight]="dataSource.flight"
@@ -1,8 +1,8 @@
<section class="page-empty">
<div class="page-empty__title">
<section class="page-empty" data-testid="empty-results">
<div class="page-empty__title" data-testid="empty-state-message">
{{ 'SHARED.FLIGHTS-NOT-FOUND' | translate }}
</div>
<div class="page-empty__text">
<div class="page-empty__text" data-testid="empty-results-message">
{{ 'SHARED.FLIGHTS-NOT-FOUND-TEXT' | translate }}
</div>
</section>
@@ -20,6 +20,7 @@
type="button"
label="{{ 'SHARED.SEARCH-CANCEL' | translate }}"
(click)="handleClick()"
data-testid="loader-cancel-button"
></button>
</div>
</div>
@@ -15,12 +15,14 @@
<terminal-link
class="station__terminal"
[station]="station"
data-testid="terminal"
></terminal-link>
<terminal-link
*ngIf="oldStation"
class="station__terminal"
[station]="oldStation"
[oldValue]="true"
data-testid="terminal"
></terminal-link>
<text
@@ -1,16 +1,16 @@
<div class="flight">
<div class="flight-number" data-testid="flight-carrier-number">
<div class="flight-number" data-testid="flight-number">
<div>{{ flight | flightNumber }}</div>
<div class="status description">
{{ 'FLIGHT-STATUSES.' + flight.status | translate }}
</div>
</div>
<operator-logo-and-model [flight]="flight" [showModel]="expanded && flight.routeType !== 'MultiLeg'"></operator-logo-and-model>
<operator-logo-and-model [flight]="flight" [showModel]="expanded && flight.routeType !== 'MultiLeg'" data-testid="airline-name"></operator-logo-and-model>
<time-group [actual]="departureBlockOffTimes" [scheduled]="departure._times.scheduledDeparture"></time-group>
<time-group [actual]="departureBlockOffTimes" [scheduled]="departure._times.scheduledDeparture" data-testid="departure-time"></time-group>
<station [station]="$any(departure)"></station>
<station [station]="$any(departure)" data-testid="station-from"></station>
<div class="flight-status">
<flight-status-icon [status]="flight.status"></flight-status-icon>
@@ -25,9 +25,10 @@
align="mobile-right"
[actual]="arrivalBlockOnTimes"
[scheduled]="arrival._times.scheduledArrival"
data-testid="arrival-time"
></time-group>
<station [station]="$any(arrival)" align="mobile-right"></station>
<station [station]="$any(arrival)" align="mobile-right" data-testid="station-to"></station>
<arrow-down-icon [rotated]="expanded" [ngClass]="arrowIconClasses"></arrow-down-icon>
</div>
@@ -14,6 +14,7 @@
(click)="toggle(index)"
[flight]="$flight"
[expanded]="$flight.expanded"
data-testid="flight-result-header"
></board-flight-header>
<ng-container *ngIf="$flight.expanded">
@@ -18,7 +18,7 @@
{{ 'DISPATCH.' + departure.dispatch | translate }}
</property>
<property description="{{ 'SHARED.NUMBER-EXIT' | translate }}" *ngIf="departure.gate">
<property description="{{ 'SHARED.NUMBER-EXIT' | translate }}" *ngIf="departure.gate" data-testid="gate">
{{ departure.gate | translate }}
</property>
@@ -18,7 +18,7 @@
{{ 'DISPATCH.' + arrival.dispatch | translate }}
</property>
<property description="{{ 'SHARED.NUMBER-EXIT' | translate }}" *ngIf="arrival.gate">
<property description="{{ 'SHARED.NUMBER-EXIT' | translate }}" *ngIf="arrival.gate" data-testid="gate">
{{ arrival.gate }}
</property>
@@ -22,8 +22,8 @@
<section-number
[number]="leg.crossIndex"
></section-number>
<div class="flight-number">
<div class="flight-number__code">
<div class="flight-number" data-testid="flight-details-number">
<div class="flight-number__code" data-testid="flight-number">
{{ flight | flightNumber }}
</div>
<div class="flight-number__code-sharing">
@@ -1,9 +1,9 @@
<section class="frame">
<div class="error-page-girl" [ngClass]="'errorCode-' + errorCode"></div>
<div class="error-page-content">
<div class="error-page-code">{{ errorCode }}</div>
<div class="error-page-title">{{ title || 'PAGE500.HEADER' | translate }}</div>
<div class="error-page-description">{{ description || 'PAGE500.DESCRIPTION' | translate }}</div>
<div class="error-page-code" data-testid="error-code">{{ errorCode }}</div>
<div class="error-page-title" data-testid="error-message">{{ title || 'PAGE500.HEADER' | translate }}</div>
<div class="error-page-description" data-testid="error-description">{{ description || 'PAGE500.DESCRIPTION' | translate }}</div>
<!-- search should not be on error page. commented in case the ask to return it back-->
<div class="error-page-search">
@@ -15,13 +15,13 @@
<div class="sort-note">{{ footnotes }}</div>
</div>
<div class="sort-container">
<button pButton type="button" #departureUp class="sort sort--up" (click)="onSort(ScheduleSortMode.departureUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.departureUp }">
<button pButton type="button" #departureUp class="sort sort--up" (click)="onSort(ScheduleSortMode.departureUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.departureUp }" data-testid="sort-option-departure-asc">
<svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-up" />
</svg>
</button>
<button pButton type="button" #departureDown class="sort sort--down" (click)="onSort(ScheduleSortMode.departureDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.departureDown }">
<button pButton type="button" #departureDown class="sort sort--down" (click)="onSort(ScheduleSortMode.departureDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.departureDown }" data-testid="sort-option-departure-desc">
<svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-down" />
</svg>
@@ -33,13 +33,13 @@
{{ 'SCHEDULE.SEARCH-RESULT-TIME' | translate }}
</div>
<div class="sort-container">
<button pButton type="button" #timeUp class="sort sort--up" (click)="onSort(ScheduleSortMode.timeUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.timeUp }">
<button pButton type="button" #timeUp class="sort sort--up" (click)="onSort(ScheduleSortMode.timeUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.timeUp }" data-testid="sort-option-time-asc">
<svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-up" />
</svg>
</button>
<button pButton type="button" #timeDown class="sort sort--down" (click)="onSort(ScheduleSortMode.timeDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.timeDown }">
<button pButton type="button" #timeDown class="sort sort--down" (click)="onSort(ScheduleSortMode.timeDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.timeDown }" data-testid="sort-option-time-desc">
<svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-down" />
</svg>
@@ -52,13 +52,13 @@
<div class="sort-note">{{ footnotes }}</div>
</div>
<div class="sort-container">
<button pButton type="button" #arrivalUp class="sort sort--up" (click)="onSort(ScheduleSortMode.arrivalUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.arrivalUp }">
<button pButton type="button" #arrivalUp class="sort sort--up" (click)="onSort(ScheduleSortMode.arrivalUp)" [ngClass]="{ active: sortBy === ScheduleSortMode.arrivalUp }" data-testid="sort-option-arrival-asc">
<svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-up" />
</svg>
</button>
<button pButton type="button" #arrivalDown class="sort sort--down" (click)="onSort(ScheduleSortMode.arrivalDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.arrivalDown }">
<button pButton type="button" #arrivalDown class="sort sort--down" (click)="onSort(ScheduleSortMode.arrivalDown)" [ngClass]="{ active: sortBy === ScheduleSortMode.arrivalDown }" data-testid="sort-option-arrival-desc">
<svg class="svg--arrow">
<use xlink:href="/assets/img/sprite.svg#arrow-down" />
</svg>
@@ -1,5 +1,5 @@
<ng-container *ngIf="scheduleItem">
<div class="left">
<div class="left" data-testid="schedule-result">
<div class="description" [style.opacity]="scheduleItem.flights.length ? '1' : '0.5'">
{{ 'DAYS.' + scheduleItem.dayOfWeek | translate }}
</div>
@@ -1,7 +1,7 @@
<div class="calendar">
<label class="label--filter">{{ label | translate }}</label>
<tooltip *ngIf="error">{{ error | translate }}</tooltip>
<tooltip *ngIf="error" data-testid="validation-error">{{ error | translate }}</tooltip>
<div class="calendar-controls-container" [ngClass]="{ 'has-value': dateStr, 'error-value': error }">
<input
@@ -1,7 +1,7 @@
<div class="calendar">
<label class="label--filter">{{ label | translate }}</label>
<tooltip *ngIf="error">{{ error | translate }}</tooltip>
<tooltip *ngIf="error" data-testid="validation-error">{{ error | translate }}</tooltip>
<div class="calendar--mobile">
<button
+1
View File
@@ -9,6 +9,7 @@
"module": "es2020",
"moduleResolution": "node",
"target": "es2017",
"skipLibCheck": true,
"typeRoots": [
"node_modules/@types"
],
-32
View File
@@ -1,32 +0,0 @@
# Dockerfile.react Multi-stage build for standalone SSR server
# Coexists with the legacy ASP.NET Dockerfile
# Stage 1: Install dependencies
FROM node:24-slim AS deps
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Stage 2: Build standalone target
FROM deps AS build
WORKDIR /app
COPY modern.config.ts module-federation.config.ts tsconfig.json ./
COPY src/ src/
RUN pnpm build:standalone
# Stage 3: Minimal production image
FROM node:24-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist/standalone/ ./dist/standalone/
EXPOSE 8080
ENTRYPOINT ["node", "dist/standalone/index.js"]
-28
View File
@@ -1,28 +0,0 @@
# Dockerfile.remote — nginx-based static file server for remote MF artifact
# Stage 1: Install dependencies
FROM node:24-slim AS deps
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Stage 2: Build remote target
FROM deps AS build
WORKDIR /app
COPY modern.config.ts module-federation.config.ts tsconfig.json ./
COPY src/ src/
RUN pnpm build:remote
# Stage 3: Serve static files with nginx
FROM nginx:alpine AS runtime
COPY --from=build /app/dist/remote/ /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
@@ -1,17 +0,0 @@
# Frozen public barrels
**Rule.** Cross-module imports inside `src/` go through exactly these five public entries:
- `@/features/online-board`
- `@/features/schedule`
- `@/features/flights-map`
- `@/features/popular-requests`
- `@/ui`
No file outside `src/features/<feature>/` may import from `src/features/<feature>/components/...` or any deeper path. No file outside `src/ui/` may import `src/ui/primitives/Button`. Enforcement lands in sub-plan **1A-3** via `eslint-plugin-boundaries` + `no-restricted-imports`.
**Why this is frozen.** Phase 0 assumption **A1** (customer's standard remote-frontend module template) may arrive after 1A-1 ships. When it does, the rename pass documented in `rename-pass-plan.md` must be a mechanical move: rename directories, update import paths at the five barrels, done. If cross-module imports fan out through deep paths, the rename becomes a surgery across dozens of files.
**What this unblocks.** Every Phase 1 sub-plan can add *internal* files to a feature/UI directory without coordinating with other sub-plans, because nothing outside the barrel depends on internals. The barrel file itself is the review gate.
**What this costs.** Small friction when a sub-plan wants to expose a new symbol — it must update the barrel. Acceptable cost for the refactor safety it buys.
@@ -1,169 +0,0 @@
# Modern.js + MF 2.0 spike report
**Date:** 2026-04-14
**Goal:** Validate that Modern.js 2.x + `@module-federation/modern-js` can produce both standalone SSR and CDN-static remote artifacts from a single source tree, and pin versions for 1A-2.
## Pinned version matrix
| Package | Version | Source |
|---|---|---|
| `@modern-js/app-tools` | `2.70.8` | pnpm-lock.yaml of scratch project |
| `@modern-js/runtime` | `2.70.8` | pnpm-lock.yaml of scratch project |
| `@modern-js/plugin-ssr` | N/A (SSR is built into `@modern-js/app-tools` in v2.x) | verified: no separate package exists in the dep tree |
| `@module-federation/modern-js` | `2.3.2` | pnpm-lock.yaml of scratch project |
| `@module-federation/enhanced` | `2.3.2` | pnpm-lock.yaml of scratch project |
| `react` | `18.3.1` | resolved from `^18.2.0` |
| `react-dom` | `18.3.1` | resolved from `^18.2.0` |
| `@types/react` | `18.3.28` | resolved |
| `@types/react-dom` | `18.3.7` | resolved |
| `typescript` | `5.5.4` | resolved from `~5.5.0` |
| `@rspack/core` (via Modern.js) | `1.7.11` | resolved transitively |
| `@rsbuild/core` (via Modern.js) | `1.7.3` | resolved transitively |
## Dual-build feasibility
- [x] Single `modern.config.ts` with `BUILD_TARGET` branching produces both targets -- **YES**
- [x] `dist/standalone/` contains Node SSR server -- **YES** (contains `bundles/` directory with `main.js`, `main-server-loaders.js`, and SSR-rendered chunk bundles)
- [x] `dist/remote/mf-manifest.json` contains all exposed modules -- **YES** (contains `./Hello` expose with asset paths)
All three criteria pass. The `BUILD_TARGET` env var read at config time cleanly switches between SSR and CSR-only modes.
### How it works
`modern.config.ts` reads `process.env.BUILD_TARGET` and branches:
```typescript
const isRemote = process.env.BUILD_TARGET === 'remote';
export default defineConfig({
plugins: [appTools({ bundler: 'rspack' }), moduleFederationPlugin()],
server: isRemote ? {} : { ssr: { mode: 'stream' } },
output: {
distPath: { root: isRemote ? 'dist/remote' : 'dist/standalone' },
},
});
```
- `BUILD_TARGET=standalone` (or unset): SSR enabled via `server.ssr.mode: 'stream'`, outputs to `dist/standalone/`. Produces both client-side JS + Node SSR bundles in `bundles/`.
- `BUILD_TARGET=remote`: SSR disabled (empty `server` config), outputs to `dist/remote/`. Produces only client-side JS + MF artifacts. No `bundles/` directory.
Both targets get the MF plugin, so `mf-manifest.json` + `remoteEntry.js` + `@mf-types.zip` are emitted in both builds.
## Emitted mf-manifest.json shape
```json
{
"id": "spike",
"name": "spike",
"metaData": {
"name": "spike",
"type": "app",
"buildInfo": {
"buildVersion": "1.0.0",
"buildName": "modernjs-mf-spike"
},
"remoteEntry": {
"name": "remoteEntry.js",
"path": "",
"type": "global"
},
"types": {
"path": "",
"name": "",
"zip": "@mf-types.zip",
"api": "@mf-types.d.ts"
},
"globalName": "spike",
"pluginVersion": "2.3.2",
"prefetchInterface": false,
"publicPath": "/"
},
"shared": [
{
"id": "spike:react-dom",
"name": "react-dom",
"version": "18.3.1",
"singleton": true,
"requiredVersion": "^18.2.0",
"assets": {
"js": {
"async": [],
"sync": ["static/js/async/396.e11e8a41.js"]
},
"css": { "async": [], "sync": [] }
},
"fallback": ""
},
{
"id": "spike:react",
"name": "react",
"version": "18.3.1",
"singleton": true,
"requiredVersion": "^18.2.0",
"assets": {
"js": {
"async": [],
"sync": ["static/js/async/75.30dce1d1.js"]
},
"css": { "async": [], "sync": [] }
},
"fallback": ""
}
],
"remotes": [],
"exposes": [
{
"id": "spike:Hello",
"name": "Hello",
"assets": {
"js": {
"sync": ["static/js/async/__federation_expose_Hello.24756ba8.js"],
"async": []
},
"css": { "sync": [], "async": [] }
},
"path": "./Hello"
}
]
}
```
Key observations about the manifest shape:
- Top-level `id` and `name` match the `name` field in `module-federation.config.ts`.
- `metaData.remoteEntry.name` is the `filename` field from config.
- `metaData.pluginVersion` reflects `@module-federation/modern-js` version.
- `metaData.publicPath` defaults to `"/"` -- must be overridden for CDN deployment.
- `exposes[].path` uses the `./Hello` convention from config.
- `shared[]` entries include resolved `version` and `requiredVersion`.
- `@mf-types.zip` is auto-generated for TypeScript type sharing between remotes.
## Gotchas discovered
1. **`@module-federation/modern-js` is the correct package name, not `-v3`.** Both `@module-federation/modern-js` and `@module-federation/modern-js-v3` exist on npm at the same version (2.3.2), but the non-suffixed name is the primary package with the correct exports. The `-v3` variant appears to be an alias. Use `@module-federation/modern-js`.
2. **Modern.js 3.x (v3.1.3) is incompatible with `@module-federation/modern-js` v2.3.2.** Modern.js 3.x uses Rspack 2.0.0-rc.0, which has breaking API changes (`api.modifyWebpackConfig` removed, Rspack hook shape changes). The MF plugin crashes with `TypeError: api.modifyWebpackConfig is not a function` (SSR plugin) and `TypeError: Cannot read properties of undefined (reading 'tap')` (EmbedFederationRuntimePlugin). Use Modern.js 2.x (v2.70.8) which ships Rspack 1.7.11.
3. **`routes/layout.tsx` is mandatory.** Modern.js v2 requires a root layout component at `src/routes/layout.tsx`. Without it, the build fails with `Error: The root layout component is required`. The generated scaffold from `@modern-js/create` includes this automatically, but manual project setup must add it.
4. **SSR stream mode logs a misleading warning.** When SSR is enabled with `mode: 'stream'`, the build logs `splitChunks.chunks = async is not allowed with stream SSR mode, it will auto changed to "async"`. This is a no-op warning (it changes `async` to `async`, which is the same value) -- safe to ignore.
5. **`@modern-js/plugin-ssr` does not exist as a separate package.** In Modern.js 2.x, SSR support is built into `@modern-js/app-tools`. The design spec references `@modern-js/plugin-ssr` but this is not needed -- just set `server.ssr.mode` in `modern.config.ts`.
6. **`appTools({ bundler: 'rspack' })` is required.** Without the explicit `bundler: 'rspack'` option, Modern.js 2.x defaults to webpack, which would conflict with the MF plugin's Rspack-specific code paths.
7. **`publicPath` in the manifest defaults to `"/"`**. For CDN deployment, `output.assetPrefix` must be set in `modern.config.ts` to the CDN URL (e.g., `https://cdn.example.com/flights/`). This will propagate into `metaData.publicPath` in `mf-manifest.json`.
8. **Type generation (`@mf-types.zip`) happens automatically.** The MF plugin generates a type bundle at `dist/@mf-types.zip` containing TypeScript declarations for all exposed modules. This enables type-safe consumption in host apps without manual type sharing.
## Known incompatibilities
- `@module-federation/modern-js` v2.3.2 has a peer dependency on `react@"^16.3.0 || ^17.0.0 || ^18.0.0"`, which means React 19 is not officially supported. React 18.3.1 resolves cleanly. If the project later needs React 19, the MF plugin version must be updated or the peer dep overridden.
- Modern.js 3.x (v3.1.3, released with `@modern-js/create@3.1.3`) is not compatible with `@module-federation/modern-js` v2.3.2. The MF plugin team will need to release a v3-compatible version. For now, pin to Modern.js 2.70.8.
## Decision
**GO.** The dual-build approach works cleanly with Modern.js 2.70.8 + `@module-federation/modern-js` 2.3.2. A single `modern.config.ts` with `BUILD_TARGET` env var branching produces:
- `dist/standalone/`: Node SSR server + client bundle (stream mode)
- `dist/remote/`: CDN-static MF 2.0 remote with `mf-manifest.json`, `remoteEntry.js`, typed exports
No workarounds or hacks required. The version matrix is stable and the manifest shape is well-defined. Proceed with Tasks 2-10 of Phase 1A-2 using these pinned versions.
@@ -1,23 +0,0 @@
# A1 rename-pass rework plan
**Trigger.** Phase 0 assumption **A1** — "customer's standard remote-frontend module template" — resolves to a directory layout that differs from the one this repo uses.
**Scope.** Move/rename directories inside `src/` to match the customer template. Update import paths *only at the five frozen public barrels* (see `frozen-barrels.md`). Do not restructure feature internals.
**Preconditions.**
- The frozen-barrel rule has been enforced since 1A-1 (Task 11) and 1A-3 ESLint rules are passing on `main`.
- Customer template document is in hand and reviewed for explicit directory conventions.
**Steps (to be fleshed out when A1 resolves).**
1. Create a target-layout scratch file mapping current path → new path for every file under `src/`.
2. Run the rename as a single automated pass (`git mv`) inside an isolated worktree.
3. Update `tsconfig.json` `paths` aliases if the top-level segments change.
4. Update `vitest.config.ts` aliases to match.
5. Update the five barrel files — this is the *only* hand-edit needed for consumer code.
6. Run `pnpm typecheck && pnpm lint && pnpm test` — green before commit.
7. Run all Phase 1 exit-gate checks (from master plan) — green before PR.
8. Single commit: `Rename src/ layout to match customer module template (A1)`.
**Escape valve.** If the rename touches more than the five barrels, something violated the frozen-barrel rule between 1A-1 and now. Fix the violation first (move the cross-boundary import through a barrel), then retry the rename.
**Owner.** This task is attached to 1A-1's exit gate and fires on A1 resolution, whether that happens during Phase 1 or early Phase 2.
-340
View File
@@ -1,340 +0,0 @@
# Flights Web — Operational Runbook
**Version:** 1.0 (Phase 1I)
**Last updated:** 2026-04-14
---
## 1. Incident Response Decision Tree
```
Is the service returning errors?
|
+-- YES: Check /health endpoint
| |
| +-- /health returns 503
| | -> Upstream API issue (see Section 6.1)
| |
| +-- /health returns 200 but users see errors
| | -> Application-level bug. Check logs (Section 5).
| | -> If recent deploy: rollback (Section 3).
| |
| +-- /health unreachable (connection refused / timeout)
| -> Container/VM is down.
| -> Check container orchestrator status.
| -> If all replicas down: escalate to infra team (Severity 1).
| -> If partial: rely on load balancer, investigate affected nodes.
|
+-- NO: Check for degraded performance
|
+-- Latency > 2x baseline
| -> Check OTel metrics for slow spans.
| -> Check upstream API latency.
| -> If upstream: see Section 6.1.
| -> If internal: check for memory pressure, CPU saturation.
|
+-- Intermittent errors in logs
-> Check error rate trend.
-> If rising: prepare for rollback.
-> If stable/low: monitor for 15 min, then investigate.
```
### Severity Levels
| Severity | Criteria | Response Time | Who to Page |
|----------|----------|---------------|-------------|
| S1 | Service fully down, all users affected | Immediate | On-call engineer + team lead |
| S2 | Partial outage, >10% error rate | 15 min | On-call engineer |
| S3 | Degraded performance, no data loss | 1 hour | On-call engineer (next business day if after hours) |
| S4 | Minor issue, workaround exists | Next business day | Assigned engineer |
---
## 2. Canary Rollout Procedure
### Pre-rollout Checklist
- [ ] All CI checks pass (typecheck, lint, test)
- [ ] Docker images built and pushed to registry
- [ ] Rollback image tag identified (current production tag)
- [ ] Monitoring dashboards open
### Rollout Steps
1. **Deploy canary** (5% traffic) to a single node in one geographic region
2. **Monitor for 10 minutes:**
- Error rate must stay below 0.5%
- p99 latency must not exceed 2x baseline
- `/health` must return 200 on the canary
- No new error patterns in logs
3. **Expand to 25%** if canary is healthy
4. **Monitor for 15 minutes** with same criteria
5. **Expand to 100%** across all geographic regions
6. **Post-deploy verification:**
- `/health` returns 200 on all nodes
- Smoke test passes end-to-end
- No error rate spike in the first 30 minutes
### Abort Criteria
Roll back immediately if any of these occur during canary:
- Error rate exceeds 1%
- `/health` returns 503 on canary nodes
- p99 latency exceeds 5x baseline
- Any S1/S2 incident triggered
---
## 3. Rollback Procedure
### 3.1 Automatic Rollback
The deploy pipeline monitors `/health` after deployment. If the health check fails within the first 5 minutes post-deploy:
1. Pipeline automatically reverts to the previous image tag
2. Alert fires to the on-call channel
3. Engineer investigates the failed deployment logs
**No manual action required** for auto-rollback. Verify the rollback succeeded by checking:
- `/health` returns 200
- Error rate returns to baseline
- Previous image tag is running on all nodes
### 3.2 Manual Rollback
If auto-rollback did not trigger or a problem is discovered later:
1. **Identify the last-known-good image tag** from the deployment history
2. **Redeploy the previous image:**
```bash
# Placeholder — actual commands depend on customer's deployment tool
# Example:
# deploy --image $REGISTRY/flights-web-standalone:$PREVIOUS_SHA --env production
```
3. **Verify rollback:**
- `/health` returns 200 on all nodes
- Error rate returns to baseline
- Smoke test passes
4. **Post-mortem:** file an incident report within 24 hours
---
## 4. Health-Check Interpretation
### Endpoint: `GET /health`
| Response | Status | Meaning | Action |
|----------|--------|---------|--------|
| `{ "status": "ok" }` | 200 | Upstream API reachable within last 60s | None |
| `{ "status": "degraded", "reason": "upstream_unreachable" }` | 503 | No successful upstream ping in 60s | Check upstream API status; see Section 6.1 |
### Common Causes of 503
1. **Upstream API is down** — check upstream service status page / monitoring
2. **Network partition** — the node cannot reach the upstream API; check network policies
3. **DNS resolution failure** — verify DNS configuration on the node
4. **Upstream API overloaded** — ping times out; coordinate with upstream team
### Load Balancer Behavior
When `/health` returns 503, the load balancer should stop routing traffic to that node. When the upstream recovers and `/health` returns 200 again, traffic automatically resumes.
---
## 5. Log Query Cookbook
Logs are shipped in JSON Lines format to the customer's log aggregation system.
### Log Structure
```json
{
"ts": "2026-04-14T12:00:00.000Z",
"level": "error",
"msg": "Request failed",
"fields": {
"traceId": "abc123",
"path": "/api/flights",
"status": 500,
"err": "TypeError: Cannot read properties of undefined"
}
}
```
### Common Queries
**Find all errors in the last hour:**
```
level:error AND ts:[now-1h TO now]
```
**Find errors for a specific trace:**
```
fields.traceId:"abc123"
```
**Find slow requests (logged by the API client on timeout):**
```
msg:"Retrying request" OR msg:"upstream_timeout"
```
**Find health-check failures:**
```
msg:"upstream_unreachable" OR (path:"/health" AND status:503)
```
**Find graceful shutdown events:**
```
msg:"SIGTERM received" OR msg:"Server closed" OR msg:"Drain timeout exceeded"
```
**Find CSP violations (if CSP reporting is enabled):**
```
msg:"csp-violation" OR fields.type:"csp-report"
```
---
## 6. Known-Failure Playbooks
### 6.1 Upstream API Down
**Symptoms:** `/health` returns 503; API client logs show retry exhaustion.
**Impact:** Users see error pages or stale data (if caching is in place).
**Steps:**
1. Confirm upstream status via the upstream team's status page or monitoring
2. If upstream is aware and working on it: monitor, no action needed on our side
3. If upstream is unaware: escalate via agreed communication channel
4. If outage exceeds 30 minutes: consider enabling a maintenance page
5. Recovery is automatic — once upstream responds, `/health` returns 200 within 60s
### 6.2 SignalR Hub Offline
**Symptoms:** Real-time flight updates stop; SignalR reconnection logs appear.
**Impact:** Users see stale board data; manual refresh still works via REST API.
**Steps:**
1. Check SignalR hub process/container status
2. Verify WebSocket connectivity from the node to the SignalR hub
3. The client auto-reconnects with exponential backoff — recovery is usually automatic
4. If hub is permanently down: REST polling fallback should activate (if implemented)
5. Inform users if downtime exceeds 5 minutes
### 6.3 CSP Violation Spike
**Symptoms:** Spike in CSP violation reports; possibly broken page functionality.
**Impact:** Scripts or styles blocked by Content-Security-Policy; UI may be partially broken.
**Steps:**
1. Check CSP violation reports for the blocked resource URL
2. If a legitimate resource is blocked: update CSP policy in `src/server/middleware/csp.ts`
3. If a third-party script is the source: investigate whether it was injected (security concern)
4. If after a deploy: the new code may reference resources not in the CSP allowlist — fix or rollback
5. CSP is in report-only mode during Phase 1 — no user impact, but violations should be tracked
### 6.4 Analytics Adapter Load Failure
**Symptoms:** `flights.analytics.load_failed` counter increases; analytics data gaps.
**Impact:** Analytics data not collected; no user-facing impact.
**Steps:**
1. Check which adapter(s) failed (Yandex.Metrica, CTM, Variocube, Dynatrace)
2. Verify the adapter's external script URL is reachable from the client
3. Check for CORS or CSP blocking the adapter script
4. If a single adapter: low priority, monitor
5. If all adapters: likely a CSP or network issue affecting all external scripts
### 6.5 OTel Exporter Unreachable
**Symptoms:** Metrics and traces stop appearing in the monitoring dashboard.
**Impact:** No observability data; no user-facing impact.
**Steps:**
1. Check the OTel collector/exporter endpoint connectivity
2. Verify the `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable is correct
3. Check for network policy changes that may block the exporter
4. The SDK buffers data locally — some data may be recoverable once the exporter is reachable again
5. If the exporter is permanently moved: update the endpoint configuration and redeploy
### 6.6 Memory Pressure / OOM Kill
**Symptoms:** Container restarts; OOM kill events in container orchestrator logs.
**Impact:** Requests in flight are dropped; load balancer reroutes to healthy nodes.
**Steps:**
1. Check container memory limits vs actual usage
2. Review recent deploys for memory leaks (new dependencies, unbounded caches)
3. If a specific route causes high memory: check for large API responses or unbounded data structures
4. Short-term: increase memory limits
5. Long-term: profile the application to find the leak; fix and redeploy
---
## Recovery SLA
**Target:** Service recovery within 6 hours after infrastructure is restored.
**Recovery steps:**
1. Infrastructure team restores VMs / containers across geographic regions
2. Deployment tool re-deploys the last-known-good image
3. `/health` checks confirm upstream connectivity
4. Load balancer re-enables traffic to recovered nodes
5. On-call engineer verifies end-to-end functionality
6. Incident report filed within 24 hours of resolution
---
## 7. Phase 6 Cutover Reference
This section cross-references the full cutover runbook at `docs/superpowers/plans/2026-04-15-phase-6-cutover.md`.
### 7.1 Traffic Ramp Quick Reference
During cutover, traffic shifts from Angular to React over 72 hours:
- **T+0h:** 5% React / 95% Angular
- **T+12h:** 25% React / 75% Angular
- **T+24h:** 50% React / 50% Angular
- **T+48h:** 100% React / 0% Angular
Each step requires explicit go/no-go from the on-call engineer. Full details in the cutover runbook Section 3.
### 7.2 Cutover Rollback
During or after the traffic ramp, if a rollback is needed:
1. Flip proxy weights back to Angular (< 1 minute)
2. Verify Angular is serving traffic via response headers
3. Confirm error rate and latency return to baseline
4. File post-mortem within 24 hours
**Trigger criteria:** error rate > 1% for 5+ minutes, p95 > 2x baseline for 10+ minutes, or > 50% of React nodes returning 503.
Full rollback procedure in the cutover runbook Section 5.
### 7.3 Post-Cutover Soak
After reaching 100% React traffic, a 7-day soak period is required before Angular decommission. Soak pass criteria:
- Zero Angular hits in access logs
- Error rate < 0.1%
- p95 < 500ms
- Core Web Vitals in "Good" threshold
- No Search Console regressions
Full soak criteria in the cutover runbook Section 6.
### 7.4 Angular Decommission
After soak sign-off:
1. Tag the Angular codebase: `git tag -a angular-final`
2. Create archive branch: `archive/angular-spa`
3. Remove Angular/ASP.NET files (requires customer approval)
4. Infrastructure cleanup
Full decommission steps in the cutover runbook Section 7.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,225 @@
# Phase 2: Online Board Feature
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement the Online Board feature -- the main flight status display with search by flight number, route, departure, or arrival. Includes start page, search results, and flight details.
**Architecture:** Feature-isolated module at `src/features/online-board/` with hooks, components, and services. Uses TanStack Query hooks from shared layer. URL-driven state with route validation.
**Tech Stack:** React 19, TanStack Query v5, Zustand, PrimeReact (Accordion, AutoComplete, Calendar), react-i18next, CSS Modules
**Depends on:** Phase 1 Foundation (complete)
---
### Task 1: Online Board Feature Hooks
**Files:**
- Create: `react-app/src/features/online-board/hooks/useOnlineBoardApi.ts`
- Create: `react-app/src/features/online-board/hooks/useFlightNavigation.ts`
- Create: `react-app/src/features/online-board/hooks/index.ts`
Feature-specific hooks wrapping shared query hooks with online-board-specific logic.
- [ ] **Step 1: Create API hook**
`useOnlineBoardApi.ts` wraps the shared query hooks with board-specific params and adds the `getFlightDaysByNumber` and `getFlightDaysByRoute` calls for calendar disabled dates.
- [ ] **Step 2: Create navigation hook**
`useFlightNavigation.ts` provides `navigateToSearch`, `navigateToDetails`, `navigateToStart` using React Router's `useNavigate` with URL builders from shared utils.
- [ ] **Step 3: Commit**
---
### Task 2: Search Filter Components
**Files:**
- Create: `react-app/src/features/online-board/components/filter/FlightNumberFilter.tsx`
- Create: `react-app/src/features/online-board/components/filter/FlightNumberFilter.module.css`
- Create: `react-app/src/features/online-board/components/filter/RouteFilter.tsx`
- Create: `react-app/src/features/online-board/components/filter/RouteFilter.module.css`
- Create: `react-app/src/features/online-board/components/filter/OnlineBoardFilter.tsx`
- Create: `react-app/src/features/online-board/components/filter/OnlineBoardFilter.module.css`
PrimeReact Accordion with 2 tabs: Flight Number search and Route search.
- [ ] **Step 1: Create FlightNumberFilter**
Input fields: flight number (SU prefix + 4 digits + optional suffix letter), date picker. Validates input, calls API for disabled dates.
- [ ] **Step 2: Create RouteFilter**
Input fields: departure autocomplete, arrival autocomplete, swap button, date picker, time range. Validates stations, calls API for disabled dates.
- [ ] **Step 3: Create OnlineBoardFilter (accordion wrapper)**
PrimeReact Accordion with 2 panels. Manages active tab state.
- [ ] **Step 4: Commit**
---
### Task 3: Flight Display Components
**Files:**
- Create: `react-app/src/features/online-board/components/board/FlightCard.tsx`
- Create: `react-app/src/features/online-board/components/board/FlightCard.module.css`
- Create: `react-app/src/features/online-board/components/board/FlightList.tsx`
- Create: `react-app/src/features/online-board/components/board/FlightList.module.css`
- Create: `react-app/src/features/online-board/components/board/FlightStatusBadge.tsx`
Components to display flight search results.
- [ ] **Step 1: Create FlightStatusBadge**
Renders colored badge based on FlightStatus enum (green=arrived, orange=delayed, red=cancelled, etc.)
- [ ] **Step 2: Create FlightCard**
Expandable card showing: departure/arrival times, cities, airline, status. Click to expand shows leg details. Uses PrimeReact Card.
- [ ] **Step 3: Create FlightList**
Renders list of FlightCards. Highlights closest flight to search time. Manages expanded state.
- [ ] **Step 4: Commit**
---
### Task 4: Flight Details Components
**Files:**
- Create: `react-app/src/features/online-board/components/details/FlightDetails.tsx`
- Create: `react-app/src/features/online-board/components/details/FlightDetails.module.css`
- Create: `react-app/src/features/online-board/components/details/FlightLegDetails.tsx`
- Create: `react-app/src/features/online-board/components/details/FlightLegDetails.module.css`
- Create: `react-app/src/features/online-board/components/details/FlightMiniList.tsx`
- Create: `react-app/src/features/online-board/components/details/FlightMiniList.module.css`
Components for the flight details page.
- [ ] **Step 1: Create FlightLegDetails**
Per-leg detail panel: departure/arrival info, times (scheduled/estimated/actual), gate, terminal, boarding status, transfer info.
- [ ] **Step 2: Create FlightMiniList**
Left sidebar list of all matching flights. Highlights selected. Click emits selection change.
- [ ] **Step 3: Create FlightDetails**
Main details view. For direct flights: single FlightLegDetails. For multi-leg: timeline with FlightLegDetails per leg + transfer segments.
- [ ] **Step 4: Commit**
---
### Task 5: Start Page
**Files:**
- Create: `react-app/src/features/online-board/components/StartPage.tsx`
- Create: `react-app/src/features/online-board/components/StartPage.module.css`
- Modify: `react-app/src/features/online-board/components/OnlineBoard.tsx`
- [ ] **Step 1: Create StartPage**
Renders: page title, OnlineBoardFilter (search form), hero section with 4 info tiles, PopularRequests widget. Uses MetaTags for SEO.
- [ ] **Step 2: Update OnlineBoard entry component**
Replace placeholder with router that renders StartPage on `/onlineboard` and other pages on sub-routes.
- [ ] **Step 3: Commit**
---
### Task 6: Search Results Page
**Files:**
- Create: `react-app/src/features/online-board/components/SearchPage.tsx`
- Create: `react-app/src/features/online-board/components/SearchPage.module.css`
- Create: `react-app/src/ui/date-tabs/DateTabs.tsx`
- Create: `react-app/src/ui/date-tabs/DateTabs.module.css`
- Create: `react-app/src/ui/time-selector/TimeSelector.tsx`
- Create: `react-app/src/ui/time-selector/TimeSelector.module.css`
Shared search results page for all 4 search types (flight number, route, departure, arrival).
- [ ] **Step 1: Create DateTabs component**
Horizontal date navigation (today, tomorrow, +2, etc.) with disabled dates. Sticky at top.
- [ ] **Step 2: Create TimeSelector component**
Time range picker (from/to sliders or inputs).
- [ ] **Step 3: Create SearchPage**
Receives search type and params. Renders: DateTabs, TimeSelector, FlightList, loading/empty states. Calls useFlightsQuery with params. Handles date/time changes via URL navigation.
- [ ] **Step 4: Commit**
---
### Task 7: Flight Details Page
**Files:**
- Create: `react-app/src/features/online-board/components/DetailsPage.tsx`
- Create: `react-app/src/features/online-board/components/DetailsPage.module.css`
- [ ] **Step 1: Create DetailsPage**
Renders: DateTabs, FlightMiniList (sidebar), FlightDetails (main). Fetches flight details via useFlightDetailsQuery. Handles flight selection from mini-list. Uses MetaTags + FlightJsonLd for SEO.
- [ ] **Step 2: Commit**
---
### Task 8: Route Definitions with Validation
**Files:**
- Create: `react-app/src/features/online-board/components/ValidatedRoute.tsx`
- Modify: `react-app/src/routes/onlineboard/page.tsx`
- Create: `react-app/src/routes/onlineboard/flight.[params].tsx`
- Create: `react-app/src/routes/onlineboard/departure.[params].tsx`
- Create: `react-app/src/routes/onlineboard/arrival.[params].tsx`
- Create: `react-app/src/routes/onlineboard/route.[params].tsx`
- Create: `react-app/src/routes/onlineboard/[params].tsx`
- [ ] **Step 1: Create ValidatedRoute wrapper**
Generic wrapper that validates URL params, redirects to 404 on invalid.
- [ ] **Step 2: Create route pages**
Each route page: parses params, validates, renders SearchPage or DetailsPage with appropriate type.
- [ ] **Step 3: Verify all routes work**
Test: `/onlineboard`, `/onlineboard/flight/1234-2026-04-03`, `/onlineboard/route/SVO-LED-2026-04-03-08002200`, `/onlineboard/SU1234-2026-04-03`
- [ ] **Step 4: Commit**
---
### Task 9: Build Verification
- [ ] **Step 1: Run production build**
Verify build succeeds with all new components.
- [ ] **Step 2: Final commit**
---
## What This Plan Produces
After completing all 9 tasks:
- Complete Online Board feature with start page, 4 search result types, and flight details
- Reusable UI components (DateTabs, TimeSelector)
- Flight display components (FlightCard, FlightList, FlightDetails)
- Search filter form with flight number and route tabs
- URL-driven routing with param validation
- SEO (MetaTags + JSON-LD) on all pages
@@ -0,0 +1,501 @@
# E2E Test Suite Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Write 200-300 comprehensive e2e tests for Angular Aeroflot app, validate 100% pass rate, then adapt and validate identical tests on React app with mocked + real API.
**Architecture:** Feature-based test organization (online-board, schedule, flights-map, popular-requests, i18n, error-states, responsive). Full state reset per test. Page Object Model for selector abstraction. Phased execution: Angular tests first → full validation → React adaptation → React validation.
**Tech Stack:** Cypress 13+, TypeScript, Moment.js (date manipulation), custom Cypress commands, fixtures with mock data
---
## Phase 1: Cypress Infrastructure Setup [SEE SPEC FOR DETAILS]
**Task 1: Set Up Cypress Base Config & Support Files for Angular**
- Create cypress.config.ts with baseUrl, timeouts, video recording
- Create cypress/tsconfig.json with TypeScript configuration
- Create cypress/support/index.ts with hook overrides
- Create cypress/support/fixtures.ts with CITIES, MOCK_FLIGHTS, POPULAR_REQUESTS, LANGUAGES
- Create cypress/support/commands.ts with custom Cypress commands for common actions
- Add npm scripts: cypress:open, cypress:run, cypress:run:all, cypress:run:feature, test:e2e
- Install Cypress and dependencies
- Commit all infrastructure files
---
## Phase 2: Write Angular Feature Tests
**Task 2: Online Board Feature Tests (~70 tests)**
Files: `ClientApp/cypress/integration/features/online-board.cy.ts`
Contains:
- Arrival Tab (20 tests): City input validation, date picker, search with results, flight details modal
- Departure Tab (20 tests): Mirror of arrival tab tests
- Flight Number Filter (15 tests): Filter by flight number, special characters, no results
- State persistence tests (15 tests): Preserve filters on navigation
Key test categories:
- Happy path: Valid search, display results, open modal
- Edge cases: Special characters, future dates, max passengers
- Error handling: API failures (404, 500, timeout), empty results, validation errors
- State: Filter persistence, modal state, pagination
- Accessibility: Keyboard navigation, focus management
- Responsive: Mobile/tablet/desktop viewports
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/online-board.cy.ts"`
Commit: `git commit -m "feat: add online board e2e tests"`
---
**Task 3: Schedule Feature Tests (~60 tests)**
Files: `ClientApp/cypress/integration/features/schedule.cy.ts`
Contains:
- Search Page (25 tests): Origin/destination autocomplete, date range, passenger count, validation
- Flight Details Page (20 tests): Display flight info, navigation (next/prev), back button
- Filters & Sorting (15 tests): Time range, airline filter, sort by departure/price/duration
Key patterns:
- Autocomplete with suggestions and filtering
- Date range picker with validation
- Spinner controls with min/max bounds
- Sorting (ascending/descending)
- Filter combinations and clear all
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/schedule.cy.ts"`
Commit: `git commit -m "feat: add schedule feature e2e tests"`
---
**Task 4: Flights Map Feature Tests (~40 tests)**
Files: `ClientApp/cypress/integration/features/flights-map.cy.ts`
Contains:
- Map Rendering (15 tests): Map loads, markers display, clustering, pan/zoom, geolocation
- Destination List (15 tests): List items render, click selects, search/filter works
- Map Interactions (10 tests): Click marker shows popup, click list item highlights map, hover effects
Key patterns:
- DOM element queries for Leaflet map
- Marker element selection and validation
- Pan/zoom coordinate calculation
- Click event triggering on map elements
- Popup visibility validation
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/flights-map.cy.ts"`
Commit: `git commit -m "feat: add flights map e2e tests"`
---
**Task 5: Popular Requests Widget Tests (~30 tests)**
Files: `ClientApp/cypress/integration/features/popular-requests.cy.ts`
Contains:
- Widget loads on page load (5 tests)
- Displays popular request items (10 tests)
- Click navigation (10 tests): Click item, verify URL and filters set correctly
- API fallback to mock data (5 tests)
Key patterns:
- Widget visibility on initial load
- Data binding from API or fixtures
- Navigation after item click
- Mock data fallback when API fails
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/popular-requests.cy.ts"`
Commit: `git commit -m "feat: add popular requests widget e2e tests"`
---
**Task 6: Internationalization (i18n) Tests (~20 tests)**
Files: `ClientApp/cypress/integration/features/i18n.cy.ts`
Contains:
- Language switcher functionality (3 tests): All 9 languages selectable, persistence
- Date format changes (5 tests): DD.MM.YYYY for ru, MM/DD/YYYY for en, etc.
- Number formatting (5 tests): Decimal separator, thousands separator per locale
- Text translations (5 tests): No missing keys, correct translations loaded
- Locale-specific UI (2 tests): Text truncation, layout changes
Languages tested: ru, en, es, fr, it, ja, ko, zh, de
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/i18n.cy.ts"`
Commit: `git commit -m "feat: add i18n e2e tests for all 9 languages"`
---
**Task 7: Error States & Recovery Tests (~30 tests)**
Files: `ClientApp/cypress/integration/features/error-states.cy.ts`
Contains:
- Network Errors (10 tests): 404 not found, 500 server error, 503 unavailable, timeout
- Validation Errors (8 tests): Required field missing, invalid format, past date, special chars
- Empty States (5 tests): No results, no matching cities, no flights
- Recovery & Retry (7 tests): Retry button works, clears error after success, reconnect on SignalR failure
Key patterns:
- Intercept with different statusCodes
- Error message visibility
- Retry button state and functionality
- SignalR connection loss and reconnection
Run: `npm run cypress:run:feature -- --spec "cypress/integration/features/error-states.cy.ts"`
Commit: `git commit -m "feat: add error states and recovery e2e tests"`
---
**Task 8: Responsive & Mobile Tests (~40 tests)**
Files: `ClientApp/cypress/integration/features/responsive.cy.ts`
Contains:
- Mobile Viewport (15 tests, 375x667 iPhone SE):
- Text readability, no overflow
- Touch targets ≥44x44px
- Hamburger menu opens/closes
- Accordion sections collapse/expand on tap
- Forms usable (not hidden behind keyboard)
- Tablet Viewport (12 tests, 768x1024 iPad):
- Layout optimized (not stretched)
- Multi-column layouts
- Touch interactions work
- Desktop Viewport (13 tests, 1920x1080):
- Layout scales correctly
- No horizontal scrolling
- All content accessible
Key patterns:
- cy.viewport() for responsive testing
- Touch events via Cypress touch commands
- Element size validation (44x44px minimum)
- Layout-specific assertions
Run: `npm run cypress:run:feature -- --spec "cypress/integration/responsive.cy.ts"`
Commit: `git commit -m "feat: add responsive design e2e tests"`
---
**Task 9: Validate Full Angular Test Suite**
- [ ] **Step 1: Run full Angular test suite**
```bash
cd ClientApp
npm run cypress:run:all
```
Expected: 200-300 tests, all passing, total time <15 minutes
- [ ] **Step 2: Generate HTML report**
```bash
npm run cypress:report
```
Expected: HTML report showing all tests with pass/fail status
- [ ] **Step 3: Identify and fix flaky tests**
If any test fails intermittently:
1. Add explicit waits for async operations
2. Use Cypress retry logic
3. Check for race conditions
4. Rerun until 3 consecutive passes
- [ ] **Step 4: Commit final Angular baseline**
```bash
git add cypress/integration/features/
git commit -m "feat: complete angular e2e test suite (200-300 tests, 100% pass rate)"
```
---
## Phase 3: Adapt Tests to React
**Task 10: Set Up Cypress for React App**
Files:
- Create: `react-app/cypress.config.ts` (copy from Angular, change baseUrl to :3000)
- Create: `react-app/cypress/tsconfig.json` (same as Angular)
- Create: `react-app/cypress/support/` (copy all from Angular)
- Create: `react-app/cypress/integration/features/` (will be adapted in next tasks)
- Modify: `react-app/package.json` (add cypress scripts)
- [ ] **Step 1: Copy Cypress config**
```bash
cd react-app
cp ../ClientApp/cypress.config.ts ./cypress.config.ts
# Edit baseUrl: 'http://localhost:3000'
cp -r ../ClientApp/cypress/support ./cypress/support
cp -r ../ClientApp/cypress/tsconfig.json ./cypress/tsconfig.json
```
- [ ] **Step 2: Install Cypress in React app**
```bash
npm install --save-dev cypress@13.x @types/node
```
- [ ] **Step 3: Add npm scripts to react-app/package.json**
```json
{
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"cypress:run:all": "cypress run --spec 'cypress/integration/**/*.cy.ts'",
"test:e2e": "cypress run -- --env API_MODE=mocked",
"test:e2e:real": "cypress run -- --env API_MODE=real BASE_URL=https://test.aeroflot.ru"
}
```
- [ ] **Step 4: Commit React Cypress setup**
```bash
git add cypress/ package.json
git commit -m "feat: set up cypress for react app with inherited configuration"
```
---
**Task 11-16: Adapt Each Feature Test to React**
For each feature (online-board, schedule, flights-map, popular-requests, i18n, error-states, responsive):
- [ ] **Step 1: Copy spec file**
```bash
cp ../ClientApp/cypress/integration/features/{feature}.cy.ts ./cypress/integration/features/{feature}.cy.ts
```
- [ ] **Step 2: Update selectors if DOM differs**
If React uses different HTML structure, update data-testid selectors in the spec file. Most selectors should remain the same if both apps implement the same test IDs.
- [ ] **Step 3: Run tests against React**
```bash
npm run test:e2e -- --spec "cypress/integration/features/{feature}.cy.ts"
```
- [ ] **Step 4: Fix failures**
For each failure:
1. Check console for selector errors
2. Update selectors in page-objects or spec file
3. Check for timing issues (add explicit waits)
4. Verify API responses match expected structure
- [ ] **Step 5: Commit adapted tests**
```bash
git add cypress/integration/features/{feature}.cy.ts
git commit -m "feat: adapt {feature} tests to react app"
```
**Repeat Steps 1-5 for each feature:**
- online-board
- schedule
- flights-map
- popular-requests
- i18n
- error-states
- responsive
---
**Task 17: Validate React Suite with Mocked API**
- [ ] **Step 1: Run full React test suite with mocked API**
```bash
cd react-app
npm run test:e2e
```
Expected: All 200-300 tests pass, <15 minutes
- [ ] **Step 2: Identify selector/timing issues**
For any failures:
- Check if selectors exist in React DOM (may differ from Angular)
- Add explicit cy.wait() if async operations need more time
- Update fixtures if mock data format differs
- [ ] **Step 3: Fix and rerun**
After fixes, rerun until 100% pass rate
- [ ] **Step 4: Commit React baseline**
```bash
git add cypress/integration/features/
git commit -m "feat: complete react e2e test suite with mocked api (100% pass rate)"
```
---
**Task 18: Validate React Suite with Real API**
- [ ] **Step 1: Run React test suite against staging backend**
```bash
cd react-app
npm run test:e2e:real
```
Expected: All tests pass, ~10-20 minutes (slower due to network)
- [ ] **Step 2: Handle network-related test flakiness**
Some tests may fail due to:
- Actual backend returning different data than mocks
- Network delays exceeding Cypress timeout
- Actual backend validation rules
Fix by:
1. Adjusting timeouts in cypress.config.ts
2. Updating assertions to match real data
3. Adding retry logic for flaky tests
- [ ] **Step 3: Validate feature parity**
Verify React and Angular behave identically:
- Same success paths work
- Same error messages shown
- Same validation rules applied
- [ ] **Step 4: Final commit**
```bash
git add cypress/
git commit -m "feat: react e2e test validation complete (mocked + real api, 100% pass rate)"
```
---
## Phase 4: Documentation & CI/CD
**Task 19: Add CI/CD Pipeline**
Files:
- Create: `.github/workflows/e2e-tests.yml`
This task ensures tests run automatically on every push/PR.
---
## Success Criteria Checklist
- [ ] **Angular Tests:** 200-300 tests, 100% pass rate, <15 min execution
- [ ] **React Tests (Mocked API):** 200-300 tests, 100% pass rate, <15 min execution
- [ ] **React Tests (Real API):** All tests pass against staging backend
- [ ] **Feature Parity:** Angular and React behave identically for all tested features
- [ ] **No Flaky Tests:** Tests pass consistently when rerun 3x
- [ ] **Performance:** No test takes >10 seconds
- [ ] **Code Coverage:** 80%+ for tested components
---
## Timeline Summary
| Phase | Tasks | Est. Time |
|-------|-------|-----------|
| 1. Setup | Task 1 | 30 mins |
| 2. Angular Tests | Tasks 2-8 | 4 hours |
| 2. Angular Validation | Task 9 | 1 hour |
| 3. React Setup | Task 10 | 30 mins |
| 3. React Adaptation | Tasks 11-16 | 3 hours |
| 3. React Validation (Mocked) | Task 17 | 1 hour |
| 3. React Validation (Real) | Task 18 | 1 hour |
| 4. CI/CD | Task 19 | 30 mins |
| **Total** | | **11-12 hours** |
Work continues until all success criteria are met.
---
## Implementation Notes
### Selector Strategy
If React app has different HTML structure, selectors will differ. Use `data-testid` attributes consistently across both apps:
```typescript
// Both Angular and React must implement these selectors:
[data-testid="arrival-city-input"]
[data-testid="search-button"]
[data-testid="flight-result"]
// etc.
```
### Fixture Updates
If real API returns different data structure, update fixtures in `cypress/support/fixtures.ts` to match.
### Timing Adjustments
If React has slower load times:
1. Increase `pageLoadTimeout` in cypress.config.ts
2. Add explicit `cy.wait()` for specific API calls
3. Use `cy.intercept(...).as('name')` and `cy.wait('@name')`
### Parallel Execution (Optional)
If <15 min time is not acceptable, enable parallel execution:
```bash
npm run cypress:run -- --parallel
```
Requires Cypress Dashboard account.
---
## Key Files Reference
**Angular Tests:**
- `ClientApp/cypress.config.ts` - Configuration
- `ClientApp/cypress/support/commands.ts` - Custom commands (reused by React)
- `ClientApp/cypress/support/fixtures.ts` - Mock data (shared with React)
- `ClientApp/cypress/integration/features/*.cy.ts` - Feature test specs
**React Tests:**
- `react-app/cypress.config.ts` - Same as Angular, different baseUrl
- `react-app/cypress/support/` - Copy of Angular support folder
- `react-app/cypress/integration/features/*.cy.ts` - Adapted feature tests
---
## Execution Workflow (Recommended: Subagent-Driven Development)
```
Task 1 (Setup) → Validate
Tasks 2-8 (Write Angular Tests) → Run in parallel via subagents
Task 9 (Validate Angular) → All tests pass?
Tasks 10-16 (React Setup + Adaptation) → Run in parallel
Task 17 (Validate React/Mocked) → All tests pass?
Task 18 (Validate React/Real API) → All tests pass?
Task 19 (CI/CD) → Pipeline working?
DONE: 200-300 tests passing on both Angular and React
```
Use `superpowers:subagent-driven-development` to parallelize tasks 2-8, 11-16.
File diff suppressed because it is too large Load Diff
@@ -1,975 +0,0 @@
# Phase 1 — Foundation MASTER Plan
> **This document is a plan INDEX, not an executable plan.** It lists the Phase 1 sub-plans, their dependency order, the contracts each sub-plan exports for downstream sub-plans to consume, and the shared files that cross sub-plan boundaries.
>
> **Do not execute this document directly.** Each sub-plan is a separate file under `docs/superpowers/plans/` with its own TDD-granular tasks. They are written on demand by re-invoking the `superpowers:writing-plans` skill with a sub-plan-specific prompt.
**Goal of Phase 1:** Build the complete Modern.js + Module Federation 2.0 foundation on which all four feature migrations (Phases 25) will be implemented. Nothing in Phase 1 ships to production users — the output is a working dual-build artifact deployed to the `testing` environment with all observability, security, and CI pipelines live.
**Phase 1 exit gate** (must pass before Phase 2 starts):
- Both build targets (standalone SSR + MF 2.0 remote) produce valid artifacts in CI.
- `mf-manifest.json` is served from the `testing` environment and consumable by a test host.
- The smoke route (`/ru/smoke`) renders via SSR in `testing` with all observability pipelines (logger, metrics, analytics) emitting correctly, verified by inspecting the log / metrics / analytics capture endpoints.
- All Phase 1 CI gates pass on `main`: lint, typecheck, unit (70%+ coverage on `src/features/` + `src/shared/` + `src/ui/` + `src/observability/`), bundle size, security scan.
- Security hardening live: CSP with per-request nonce (including the stream-transform nonce injection workaround for React issue #24883), HTTP security headers, dependency scanning green.
- Canary deploy pipeline functional (smoke route deployed via canary path with auto-rollback on health-check failure).
- Operational runbook published in `docs/superpowers/phase-1/runbook.md`.
- Responsive baseline assertions passing on root layout + error pages at 320 / 768 / 1280 / 1920 widths.
**Reference spec:** `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md`. Phase 1 implements sections §1–§8 of the spec (everything except the feature ports in §9.2 Phase 2+).
**Phase 0 prerequisites — split gate.** Phase 0 produces a customer-confirmation checklist answering assumptions A1A9. Phase 1 splits the gate:
- **Hard blockers** (1A-1 does not start until all resolved): **A2** (CDN vendor), **A3** (CI provider), **A5** (ASP.NET host fate), **A6** (metrics endpoint), **A8** (prod URL / access logs), **A9** (Node 24 available on customer deploy VMs — new).
- **Stub-allowed** (Phase 1 ships stubs, swap task pending customer response): **A1** (module template), **A4** (log format), **A7** (analytics vendor credentials).
---
## Sub-plan inventory
| ID | Sub-plan | Spec section | Estimated size | File |
|---|---|---|---|---|
| **1A-1** | Project skeleton (src tree, tsconfig, eslint base, env, package.json, zod) | §1.3, §2.1 | Medium | `2026-04-14-phase-1a1-skeleton.md` (TBW) |
| **1A-2** | MF 2.0 + dual build targets + RemoteLoader + MF spike | §2.1, §2.2, §2.3, §2.4, §2.5 | Medium | `2026-04-14-phase-1a2-mf-builds.md` (TBW) |
| **1A-3** | ESLint boundaries + layered dependency rules | §1.2 | Small | `2026-04-14-phase-1a3-eslint-boundaries.md` (TBW) |
| **1B** | CI pipeline | §8.5 | Medium | `2026-04-14-phase-1b-ci.md` (TBW) |
| **1C** | i18n runtime + locale port | §6.1–§6.4 | Medium | `2026-04-14-phase-1c-i18n.md` (TBW) |
| **1D** | API client + caches + circuit breaker | §4.1, §4.2 | Medium | `2026-04-14-phase-1d-api-client.md` (TBW) |
| **1E** | SignalR wrapper + `useLiveFlights` hook | §4.4 | Medium | `2026-04-14-phase-1e-signalr.md` (TBW) |
| **1F-layout** | Root layout + locale layout + error routes + smoke route + ErrorBoundary + error→HTTP mapper | §1.3, §3.1, §3.3 | Medium | `2026-04-14-phase-1f-layout.md` (TBW) |
| **1F-seo** | SeoHead + hreflang builder + JsonLdRenderer | §3.6, §6.5, §6.6, §6.7, §6.8 | Small | `2026-04-14-phase-1f-seo.md` (TBW) |
| **1G-logger** | Logger types + JSON-lines transport + console transport + provider | §7.1, §7.2 | Medium | `2026-04-14-phase-1g-logger.md` (TBW) |
| **1G-metrics** | OpenTelemetry init (server/browser) + custom metric instruments | §7.3, §7.6, §7.7 | Medium | `2026-04-14-phase-1g-metrics.md` (TBW) |
| **1G-analytics** | Analytics facade + four stub adapters (Yandex.Metrica, CTM, Variocube, Dynatrace) | §7.4 | Small | `2026-04-14-phase-1g-analytics.md` (TBW) |
| **1H** | Security hardening (CSP + nonce stream transform + headers + storage) | §8.1 | Small | `2026-04-14-phase-1h-security.md` (TBW) |
| **1I** | Deploy pipeline + health + graceful shutdown + runbook | §8.3, §8.5 | Medium | `2026-04-14-phase-1i-deploy.md` (TBW) |
Sizes: **Small** ≈ 510 tasks, **Medium** ≈ 1020 tasks. (No "Large" sub-plans after the 1A/1F/1G splits.)
Parity harnesses (URL / SEO / VRT) and real parity tests are **deferred to Phase 2**, to be designed against the first real feature migration rather than against a synthetic smoke route.
---
## Dependency graph
```
┌─────────────────┐
│ 1A-1 Skeleton │◄── every sub-plan depends on 1A-1
└────────┬────────┘
┌─────────────────┐
│ 1A-2 MF 2.0 │
│ + builds + MF │
│ spike first │
└────────┬────────┘
┌─────────────────┐
│ 1A-3 ESLint │
│ boundaries │
└────────┬────────┘
┌──────────────────────────┼──────────────────┬──────────────────┐
▼ ▼ ▼ ▼
┌──────┐ ┌────────┐ ┌───────────┐ ┌──────────┐ ┌──────────────┐ ┌─────────┐
│ 1B │ │ 1C i18n │ │ 1D API │ │ 1E Sig- │ │ 1G-logger │ │ 1F-seo │
│ CI │ │ │ │ client │ │ nalR │ │ (type-only │ │ (pure │
│ │ │ │ │ │ │ │ │ file first) │ │ funcs) │
└──┬───┘ └────┬───┘ └─────┬────┘ └──────────┘ └──────┬───────┘ └────┬────┘
│ │ │ │ │
│ │ │ ┌─────────────────────┘ │
│ │ │ ▼ │
│ │ │ ┌──────────────┐ │
│ │ │ │ 1G-metrics │ │
│ │ │ │ (depends on │ │
│ │ │ │ 1G-logger) │ │
│ │ │ └──────┬───────┘ │
│ │ │ │ │
│ │ │ ▼ │
│ │ │ ┌──────────────┐ │
│ │ │ │ 1G-analytics │ │
│ │ │ │ (depends on │ │
│ │ │ │ 1G-logger) │ │
│ │ │ └──────┬───────┘ │
│ │ │ │ │
│ └────────────┴─────────┴────────────────────────────────────┤
│ ▼ │
│ ┌──────────────────────────────┐ │
│ │ 1F-layout (root layout + │◄─────────────────┘
│ │ error routes + smoke route) │
│ │ (consumes 1C + 1D + │
│ │ 1G-logger/metrics/analytics │
│ │ + 1F-seo) │
│ └──────┬───────────────────────┘
│ ▼
│ ┌──────────────────────────────┐
│ │ 1H Security hardening │
│ │ (middleware + nonce stream │
│ │ transform into 1F-layout) │
│ └──────┬───────────────────────┘
│ │
▼ ▼
┌───────────────────────────────────────┐
│ 1I Deploy pipeline + runbook │
│ (consumes 1A-2 + 1B + 1H) │
└───────────────────────────────────────┘
```
### Execution order
**Serial (1 engineer):** 1A-1 → 1A-2 → 1A-3 → 1C → 1F-seo → 1G-logger → 1D → 1G-metrics → 1G-analytics → 1F-layout → 1H → 1I → 1B → 1E.
Rationale:
- 1A-1/2/3 unlock everything.
- 1C is cheap and 1F-layout depends on it.
- 1F-seo is pure functions, no runtime deps on 1C/1D/1G — slot it in early.
- 1G-logger's type-only file (`Logger`, `LogFields`, `LogLevel`) must ship early since 1A-1's `HostContract` depends on it. (See "Logger type extraction" below.)
- 1D depends on 1G-logger's types (for request-scoped child loggers).
- 1G-metrics and 1G-analytics depend on 1G-logger.
- 1F-layout consumes 1C + 1D + all three 1G sub-plans + 1F-seo — it's the integration point.
- 1H modifies files 1F-layout creates, so it follows.
- 1I consumes 1A-2 + 1B + 1H.
- 1B can be slotted earlier if CI-enforced gates become blocking; the order above assumes PR-quality gates are enough until 1I.
- 1E can come last because its only consumer is Phase 2 Online Board.
**Parallel (2+ engineers):** After 1A-3 ships, the following can proceed in parallel: **1B, 1C, 1D, 1E, 1F-seo, 1G-logger** (and its dependents 1G-metrics/1G-analytics). 1F-layout and later remain sequential because of shared-file constraints.
### Critical path
**1A-1 → 1A-2 → 1A-3 → 1C → 1F-layout → 1H → 1I → exit gate** is the critical path for one engineer. 1G-logger / 1G-metrics / 1G-analytics / 1D / 1F-seo all feed 1F-layout but sit off the critical path once 1C ships (they run in parallel with 1C if there's more than one engineer, or in series but interleaved if there's only one).
---
## Logger type extraction (cross-cutting)
`HostContract` in 1A-1 has an optional `logger?: Logger` field (design spec §2.4). `Logger` is defined in 1G-logger. To avoid a plan-dependency cycle:
- **1G-logger's first task** ships `src/observability/logger/types.ts` containing **only the type definitions** (`Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport`). No runtime code, no transports.
- **1A-1** imports `Logger` from `@/observability/logger/types` in its `HostContract` definition.
- Runtime logger implementation (transports, provider, factories) lands later in 1G-logger and does not retroactively affect 1A-1.
This "type-only file first" pattern is the canonical workaround for plan-order cycles in this master plan; any other sub-plan hitting a similar cycle follows the same pattern.
---
## Contracts — what each sub-plan exports
This is the section that lets sub-plans be written and reviewed independently. Every sub-plan must produce its contracts without breaking changes once another sub-plan depends on them. Contracts are enforced via TypeScript types — any change to an exported type is a cross-sub-plan review gate.
### 1A-1 — Project skeleton contracts
**Exports:**
- **Project layout** — the `src/` directory tree from design spec §1.3. Every other sub-plan adds files inside this tree. No sub-plan is allowed to create top-level directories outside `src/` / `tests/` / `scripts/` / `docs/` without explicit call-out.
- **`tsconfig.json`** — strict mode, path aliases (`@/``src/`, `@phase0/``scripts/phase-0/`), `noUncheckedIndexedAccess`, `isolatedModules`.
- **`.eslintrc.cjs` base** — the baseline config. 1A-3 adds the boundaries rules on top.
- **`package.json` scripts** — `dev`, `build:standalone`, `build:remote`, `build:both`, `test`, `test:coverage`, `lint`, `typecheck`. Pinned **Node 24** via `engines` and `.nvmrc`.
- **`package.json` dependencies owned by 1A-1** — `zod` (for env validation and `storage.ts` schema validation — used by both 1A-1 `src/env/` and 1H `src/shared/storage.ts`, so it lives in the common base).
- **`src/env/index.ts`** — runtime env-var reader returning a Zod-validated typed `Env` object. Other sub-plans read env vars exclusively through this module.
- **`src/host-contract.ts`** — the `HostContract` type, reproduced byte-for-byte from design spec §2.4:
```ts
import type { Logger } from "@/observability/logger/types";
export interface HostContract {
locale: string; // "ru", "en", ...
canonicalOrigin: string; // "https://flights.aeroflot.ru"
navigate?: (path: string) => void; // optional deep-link nav override
consent?: { analytics: boolean; telemetry: boolean }; // optional, else assumed true
logger?: Logger; // optional host logger merge
}
```
- **Empty feature and UI barrel files** — `src/features/{online-board,schedule,flights-map,popular-requests}/index.ts` and `src/ui/index.ts` exist but export nothing. Phase 2+ populates them. **Exit-gate rule: the public barrel surface is frozen — no other sub-plan creates new cross-boundary imports outside these barrels.**
**TypeScript contracts:**
```ts
// src/env/index.ts
export interface Env {
NODE_ENV: "development" | "testing" | "staging" | "production";
BUILD_TARGET: "standalone" | "remote";
PROD_ORIGIN: string;
API_BASE_URL: string;
SIGNALR_HUB_URL: string;
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
OTEL_EXPORTER_OTLP_HEADERS?: string;
LOGS_ENDPOINT?: string;
ANALYTICS_ENABLED: AnalyticsProviders; // imported from src/observability/analytics/types
VERSION: string; // git sha, injected at build time
}
export function getEnv(): Env;
```
**Exit gate for 1A-1:**
- `pnpm typecheck` and `pnpm lint` green on an empty src tree plus the env module.
- `src/env/index.ts` round-trips a test `Env` object through Zod validation and surfaces a readable error on malformed input.
- **Frozen barrel rule** documented in 1A-1's deliverables — subsequent sub-plans may add exports *to* these barrels but may not create parallel public surfaces.
- **Rename-pass rework task** attached to this sub-plan's exit gate: if A1 (customer module template) resolves after 1A-1 ships, a bounded rename pass moves the src tree to match the template without rewriting internals. The frozen barrel rule ensures this rename is a mechanical operation.
---
### 1A-2 — MF 2.0 + dual build targets + RemoteLoader contracts
**First task (gate):** **Modern.js + MF 2.0 spike.** A 24 hour timeboxed experiment that boots the simplest possible Modern.js + Rspack + `@module-federation/modern-js` end-to-end example (a single exposed hello-world component), documents gotchas in `docs/superpowers/phase-1/modernjs-mf-spike.md`, and produces a **pinned version matrix** (Modern.js X.Y.Z + `@module-federation/modern-js` A.B.C + Rspack P.Q.R). If the spike fails or reveals a hard blocker, 1A-2 halts and the issue escalates to the customer before committing to Modern.js.
**Exports:**
- **`modern.config.ts`** — the Modern.js build config with `BUILD_TARGET=standalone|remote` branching. Later sub-plans (1G-metrics, 1H, 1I) modify this file via explicit "modify" tasks — 1A-2 owns the base structure.
- **Dual build targets** — `pnpm build:standalone` produces `dist/standalone/` (Node server + client bundle); `pnpm build:remote` produces `dist/remote/` (static chunks + `mf-manifest.json`).
- **`src/mf/remote-loader.ts`** — Module Federation 2.0 runtime API wrapper so Phase 2+ can consume other customer remotes without touching `modern.config.ts` again:
```ts
export interface RemoteModuleRef<T = unknown> {
name: string; // remote name (e.g. "customer-ui")
module: string; // exposed module path (e.g. "./Header")
}
export function loadRemoteModule<T>(ref: RemoteModuleRef<T>): Promise<T>;
export function registerRemote(entry: { name: string; entry: string }): void;
```
- **`src/mf/host-entry.ts`** — the entry point for the remote build target that consumes `HostContract` and bootstraps the React subtree into a host-provided mount point.
**TypeScript contracts:** `RemoteModuleRef`, `loadRemoteModule`, `registerRemote` (above). `HostContract` is imported from 1A-1 (`src/host-contract.ts`).
**Exit gate for 1A-2:**
- Spike doc committed with pinned version matrix.
- `pnpm build:both` produces `dist/standalone/` (Node server + client bundle) and `dist/remote/` (static chunks + `mf-manifest.json`) with zero type errors.
- A minimal integration test loads a test remote via `loadRemoteModule` and asserts the returned module.
---
### 1A-3 — ESLint boundaries contracts
**Exports:**
- **`.eslintrc.cjs` additions** — `eslint-plugin-boundaries` rules enforcing the layered dependency direction from design spec §1.2:
- `features/` cannot import `routes/` or `mf/`
- `ui/` cannot import `features/`
- `shared/` cannot import `features/`, `routes/`, `mf/`, `observability/`
- `observability/` cannot import `features/`, `routes/`, `mf/`
- **`no-restricted-imports` rules:**
- `@opentelemetry/sdk-metrics` — allowed only in `src/observability/metrics/otel.ts` (keeps module-level instrument exports safe by forcing meter acquisition through `@opentelemetry/api`'s proxy meter).
- `window.localStorage` / `window.sessionStorage` — allowed only in `src/shared/storage.ts`.
- `@microsoft/signalr` — forbidden in any file that's part of the SSR bundle (enforced via file-path pattern).
- `react-i18next` — forbidden outside `src/i18n/provider.tsx` (feature code goes through the re-export).
**Exit gate for 1A-3:** Each rule has a fabricated violation test in `tests/eslint/` that asserts the rule fires.
---
### 1B — CI pipeline contracts
**Exports:**
- **`.github/workflows/ci.yml`** (or equivalent for the CI provider chosen via A3). Runs on every PR and every push to `main`: install, lint, typecheck, unit tests, build both targets, bundle-size gate, security scan.
- **`.github/workflows/nightly.yml`** — nightly-only: contract tests, load test (stub until Phase 2), Lighthouse CI.
- **`scripts/ci/bundle-size-gate.ts`** — reads the Rspack build stats and compares against budgets in `docs/superpowers/phase-1/bundle-budgets.json`. Fails the build if any budget is exceeded.
- **`scripts/ci/check-coverage-delta.ts`** — reads Vitest coverage JSON and the prior-commit's coverage JSON (from the base branch), fails if coverage decreases. Uses `git show <base>:coverage-summary.json` to read the baseline; tolerates missing baseline (first run).
**TypeScript contracts:** none exported (scripts are CI-internal).
**Exit gate for 1B:** A PR with a trivially-broken test fails CI; a PR with a green test passes CI in under 20 minutes. Bundle-size gate flags a fabricated regression.
---
### 1C — i18n runtime contracts
**Exports:**
- **`src/i18n/config.ts`** — factory that creates a request-scoped `i18next` instance configured with `i18next-icu`, loaded with a single locale's bundle:
```ts
export function createI18nInstance(options: {
locale: Language;
initialResources?: Record<string, Record<string, unknown>>;
}): Promise<i18n>;
```
- **`src/i18n/resolver.ts`** — locale resolution from URL prefix:
```ts
export type Language = "ru"|"en"|"es"|"fr"|"it"|"ja"|"ko"|"zh"|"de";
export const LANGUAGES: readonly Language[];
export function isLanguage(x: string): x is Language;
export function resolveLocaleFromPath(pathname: string): Language | null;
export function stripLocaleFromPath(pathname: string): { locale: Language; rest: string } | null;
```
- **`src/i18n/locales/{lang}/common.json`** — 9 files, ported from `ClientApp/src/assets/i18n/*.json` using the Phase 0 translation-key inventory to drop dead keys (optional; by default, all keys port). ICU MessageFormat syntax preserved byte-for-byte.
- **`src/i18n/serializer.ts`** — helpers to serialize the loaded locale bundle into the SSR HTML payload under `window.__I18N__` and rehydrate it on the client:
```ts
export function serializeI18nForHydration(i18n: i18n): string; // emits a JSON string
export function hydrateI18nFromWindow(): Promise<i18n>; // reads window.__I18N__
```
- **`src/i18n/provider.tsx`** — React Context provider + `<I18nProvider i18n={...}>` component + `useI18n()` accessor. **Re-exports `useTranslation` from `react-i18next`** so feature code never imports `react-i18next` directly (enforced by 1A-3's ESLint rule).
**Exit gate for 1C:** Vitest test renders a component with `<I18nProvider>` loaded with `ru` and asserts `t("common.someKey")` returns the Russian value. SSR + hydration roundtrip test using `renderToString` verifies no client-side re-fetch or flash.
---
### 1D — API client contracts
**Exports:**
- **`src/shared/api/client.ts`** — the `ApiClient` class:
```ts
export interface ApiClientRetryOptions {
maxRetries?: number; // default 3 (idempotent only)
timeoutFactor?: number; // default 2 (exponential backoff)
statusCodes?: number[]; // default [408, 429, 500, 502, 503, 504]
}
export interface ApiClientOptions {
baseUrl: string;
locale: Language;
traceId?: string;
fetchImpl?: typeof fetch; // for tests (ignored on server path where undici is used)
defaultTimeoutMs?: number; // default 5000
retry?: ApiClientRetryOptions;
logger?: Logger;
}
export class ApiClient {
constructor(options: ApiClientOptions);
get<T>(path: string, query?: Record<string, string | number | boolean>): Promise<T>;
post<T>(path: string, body: unknown): Promise<T>;
}
```
**Server path** uses `undici.RetryAgent` under the hood (statusCodes + timeoutFactor wired into the agent). **Client path** uses `globalThis.fetch` with a thin hand-rolled retry wrapper that applies the same config.
- **`src/shared/api/errors.ts`** — typed error classes:
```ts
export class ApiError extends Error { constructor(message: string); }
export class ApiHttpError extends ApiError { status: number; body?: unknown; }
export class ApiTimeoutError extends ApiError { timeoutMs: number; }
export class ApiNetworkError extends ApiError { cause?: Error; }
```
- **`src/shared/api/cache.ts`** — three distinct cache types:
```ts
// (1) SSR request-scoped dedup: one-shot, discarded after response
export class RequestScopedCache {
get<T>(key: string): Promise<T> | undefined;
set<T>(key: string, promise: Promise<T>): void;
}
// (2) Client-side per-tab in-memory TTL cache (count-capped)
export class ClientMemoryCache<T> {
constructor(options: { max: number; defaultTtlMs: number });
get(key: string): T | undefined;
set(key: string, value: T, ttlMs?: number): void;
delete(key: string): void;
clear(): void;
size: number;
}
// (3) Shared per-VM LRU cache with BYTE cap (~100MB), backed by lru-cache@^10
export class ServerLruCache<T> {
constructor(options: {
maxBytes: number; // e.g. 100 * 1024 * 1024
defaultTtlMs: number;
sizeCalculation?: (value: T, key: string) => number; // default: JSON.stringify length
});
get(key: string): T | undefined;
set(key: string, value: T, ttlMs?: number): void;
delete(key: string): void;
clear(): void;
calculatedSize: number; // current bytes used
}
// Key convention used by all three caches
export function cacheKey(endpoint: string, query: Record<string, unknown>, locale: Language): string;
```
Default TTLs from design spec §4.2: **30s for live data, 5 min for static reference data** (wired via call-site overrides).
- **`src/shared/api/circuit-breaker.ts`**:
```ts
export interface CircuitBreakerOptions {
failureThreshold?: number; // default 5
openDurationMs?: number; // default 30_000
}
export class CircuitBreaker {
constructor(options?: CircuitBreakerOptions);
exec<T>(fn: () => Promise<T>): Promise<T>;
reset(): void;
state: "closed" | "open" | "half-open";
}
```
- **`src/shared/api/cached-client.ts`** — **caching decorator** layered above `ApiClient` (not inside it):
```ts
export interface CachedClientOptions {
client: ApiClient;
requestScoped?: RequestScopedCache; // SSR only
clientMemory?: ClientMemoryCache<unknown>;
serverLru?: ServerLruCache<unknown>;
ttlMs?: number;
}
export class CachedApiClient {
constructor(options: CachedClientOptions);
get<T>(path: string, query?: Record<string, string | number | boolean>): Promise<T>;
}
```
Feature code opts into caching by wrapping `ApiClient` in `CachedApiClient`; uncached calls go through the raw `ApiClient`.
- **`src/shared/api/provider.tsx`** — React Context provider + `useApiClient()` hook. SSR-aware: on the server, the client is constructed per-request with the resolved locale; on the client, a single instance is shared across the tab.
**Dependency on 1G-logger:** `ApiClientOptions.logger?: Logger` consumes the type-only import from `src/observability/logger/types`. 1D must not ship before 1G-logger's type-only file.
**Package additions (1D):** `undici` (explicit dep, even though Node 24 includes it — pin for deterministic behavior), `lru-cache@^10`.
**Exit gate for 1D:** Vitest tests cover: success response deserialization; retry on `[408, 429, 500, 502, 503, 504]`; no retry on other 4xx; timeout; `Retry-After` honored on 429/503; circuit breaker open/half-open/closed transitions; request-scoped cache dedup; client-memory TTL eviction; server LRU byte cap eviction under load.
---
### 1E — SignalR wrapper contracts
**Exports:**
- **`src/shared/signalr/connection.ts`** — the reference-counted connection wrapper:
```ts
export interface HubOptions {
hubUrl: string;
reconnectDelaysMs?: number[]; // default [0, 2000, 10000, 30000]
gracePeriodMs?: number; // default 5000
}
export class SignalRConnection {
constructor(options: HubOptions);
subscribe(channel: string, handler: (message: unknown) => void): () => void;
onStatusChange(handler: (status: ConnectionStatus) => void): () => void;
get status(): ConnectionStatus;
}
export type ConnectionStatus = "idle" | "connecting" | "live" | "reconnecting" | "offline";
export function getSharedConnection(options: HubOptions): SignalRConnection;
```
- **`src/shared/hooks/useLiveFlights.ts`** — **generic** live-data hook (not hardcoded to flight-search params):
```ts
export function useLiveFlights<TParams, TData>(
params: TParams,
initialData: TData[],
config: {
hubUrl: string;
channelKey: (params: TParams) => string;
},
): { data: TData[]; connectionStatus: ConnectionStatus };
```
SSR-safe: during SSR, returns `{ data: initialData, connectionStatus: "idle" }` without importing `@microsoft/signalr` (enforced by 1A-3's ESLint SSR-bundle guard).
**Exit gate for 1E:**
- Two rapid `useEffect` mounts (Strict Mode double-invoke simulation) result in exactly one `HubConnection.start()` call.
- Unmount + remount within the grace period reuses the connection; unmount + remount after the grace period creates a fresh one.
- SSR render path does not import `@microsoft/signalr` (asserted by inspecting the SSR bundle stats for the absence of the package).
---
### 1F-layout — Root layout + routes + error mapper contracts
**Exports:**
- **`src/routes/layout.tsx`** — root HTML shell: `<html>`, `<head>`, `<Scripts>`, `<Links>`, root `<I18nProvider>`, root `<ApiClientProvider>`, root `<ErrorBoundary>`, root `<AnalyticsLoader>` (from 1G-analytics). Wrapped by `<LoggerProvider>` (from 1G-logger).
- **`src/routes/[lang]/layout.tsx`** — locale-scoped layout: validates `params.lang`, creates the request-scoped i18next instance (via 1C's `createI18nInstance`), builds the canonical URL + hreflang set (via 1F-seo's `buildHreflangSet`), passes them into `<SeoHead>`. Creates a request-scoped logger child and an OTel span (from 1G-metrics).
- **`src/routes/error/[code]/page.tsx`** — error page rendered for `code ∈ {404, 500, 503}`. Ports the existing Angular error component layout. Fully responsive (assertions below).
- **`src/routes/[lang]/smoke/page.tsx`** — smoke route that exercises every foundation subsystem: emits a log at `info`, emits a metric counter, calls `track("smoke.pageview")`, renders `{t("smoke.heading")}`, fetches a dummy API endpoint via `ApiClient.get`, renders `<SeoHead>` with canonical + hreflang. Visible in `testing` env at `/ru/smoke` and `/en/smoke`. Uses `React.lazy()` + `<Suspense>` + a server loader as a deliberate stress test of the React 18 concurrent + streaming path.
- **`src/routes/error/map.ts`** — error-to-HTTP mapper consumed by the SSR loader path:
```ts
export interface ErrorResponse {
status: 404 | 500 | 503;
headers?: Record<string, string>;
errorCode: "not_found" | "internal" | "unavailable";
}
export function errorToResponse(error: unknown): ErrorResponse;
```
Mapping rules (design spec §4.6):
- `ApiHttpError` with `status === 404` → `{ status: 404, errorCode: "not_found" }`
- `ApiHttpError` with `status` in 500599 → `{ status: 500, errorCode: "internal" }`
- `ApiTimeoutError` → `{ status: 503, headers: { "Retry-After": "30" }, errorCode: "unavailable" }`
- Unknown → `{ status: 500, errorCode: "internal" }`
- **`src/ui/errors/ErrorBoundary.tsx`** — React error boundary component. Logs the error via `useLogger()`, emits the `flights.react.error` metric, shows a fallback UI with a "Retry" button that resets the boundary's state.
**Shared file ownership flag:** `src/routes/layout.tsx` is owned by 1F-layout. Sub-plans 1G-analytics (analytics loader mount), 1G-logger (provider wrap), and 1H (CSP nonce propagation + stream transform) modify this file via explicit tasks, referencing the 1F-layout-shipped version as the base.
**Exit gate for 1F-layout:**
- `/ru/smoke` and `/en/smoke` render via SSR in the `testing` env.
- `<head>` contains title, description, canonical, 9 hreflang alternates + `x-default`, OG tags, one JSON-LD block, one `<meta>` description (generated by 1F-seo).
- Missing `lang` URLs redirect 301 to `/ru/smoke`.
- 404 route returns HTTP 404 with the error page body.
- **Responsive baseline (R2):** Playwright renders `/ru/smoke` and `/ru/error/404` at widths **320, 768, 1280, 1920** — no horizontal scroll, all critical content visible, error page layout stable. Baselines committed under `tests/fixtures/phase-1/responsive/`.
---
### 1F-seo — SeoHead + hreflang + JsonLdRenderer contracts
**Exports:**
- **`src/ui/seo/SeoHead.tsx`** — the `<SeoHead>` component from design spec §6.5:
```ts
export interface SeoHeadProps {
title: string;
description: string;
canonical: string;
hreflang: Array<{ lang: Language | "x-default"; href: string }>;
og: {
title: string; description: string; url: string;
image: string; type: "website" | "article";
locale: string; siteName: string;
};
twitter?: {
card: "summary" | "summary_large_image";
title?: string; description?: string; image?: string;
};
jsonLd?: unknown | unknown[];
noindex?: boolean;
}
export function SeoHead(props: SeoHeadProps): JSX.Element;
```
- **`src/shared/seo/hreflang.ts`** — reusable reciprocal-hreflang builder:
```ts
export function buildHreflangSet(args: {
canonicalOrigin: string;
pathWithoutLocale: string; // e.g. "/onlineboard/flight/SU100-2025-01-15"
}): Array<{ lang: Language | "x-default"; href: string }>;
```
Per design spec §1.4: `x-default` points to the Russian (`ru`) variant.
- **`src/shared/seo/json-ld.tsx`** — `schema-dts`-typed JSON-LD renderer helper:
```ts
import type { Thing } from "schema-dts";
export interface JsonLdRendererProps {
data: Thing | Thing[];
}
export function JsonLdRenderer(props: JsonLdRendererProps): JSX.Element;
export function serializeJsonLd(data: Thing | Thing[]): string;
```
Phase 2+ feature sub-plans ship typed builders (`buildFlightJsonLd`, `buildFlightSearchResultsJsonLd`, etc.) that consume `JsonLdRenderer` — no infrastructure work needed per feature.
**Exit gate for 1F-seo:** Unit tests cover `buildHreflangSet` for the 9 languages + `x-default`, `SeoHead` emits the full `<head>` shape, and `JsonLdRenderer` round-trips a typed `Thing` through `serializeJsonLd` → DOM string.
---
### 1G-logger — Logger contracts
**Sequencing:** The **first task** of 1G-logger is `src/observability/logger/types.ts` shipping **only type definitions**, so 1A-1's `HostContract` can import `Logger` without waiting on the runtime implementation.
**Exports:**
- **`src/observability/logger/types.ts`** (type-only, ships first):
```ts
export type LogLevel = "debug" | "info" | "warn" | "error";
export type LogFields = Record<string, string | number | boolean | null | undefined>;
export interface Logger {
debug(msg: string, fields?: LogFields): void;
info(msg: string, fields?: LogFields): void;
warn(msg: string, fields?: LogFields): void;
error(msg: string, fields?: LogFields & { err?: Error }): void;
child(context: LogFields): Logger;
}
export interface LogTransport {
write(record: LogRecord): void;
flush(): Promise<void>;
}
export interface LogRecord {
ts: string; level: LogLevel; msg: string; fields: LogFields;
}
```
- **`src/observability/logger/json-lines-transport.ts`** — default `JsonLinesHttpTransport` with batching, backpressure drop, redaction, `sendBeacon` flush.
- **`src/observability/logger/console-transport.ts`** — dev-mode transport.
- **`src/observability/logger/root.ts`** — `createRootLogger()` factory that reads env config and picks a transport.
- **`src/observability/logger/provider.tsx`** — React context + `useLogger()` hook. Server: request-scoped child logger. Client: shared root logger.
**A4-trigger task (from requirements gap R3):** When the customer provides the log format (A4 resolution), a follow-on task creates `src/observability/logger/customer-format-transport.ts` implementing the customer-specified format, and updates `createRootLogger()` to pick this transport by default. **No consumer code changes** — the `Logger` interface is stable. This task is attached to 1G-logger's exit gate and fires on A4 resolution (could be during Phase 1, or deferred to early Phase 2).
**Exit gate for 1G-logger:**
- Type-only file ships first, verified by 1A-1 successfully importing `Logger`.
- Vitest tests cover: batching + flush; redaction of sensitive fields; transport backpressure drops old records; dev-mode console transport; `child()` context propagation.
---
### 1G-metrics — OpenTelemetry + custom instruments contracts
**Exports:**
- **`src/observability/metrics/otel.ts`** — OpenTelemetry setup (the **only** file allowed to import from `@opentelemetry/sdk-metrics`, enforced by 1A-3):
```ts
export function initServerOtel(env: Env): void; // called once per Node process
export function initBrowserOtel(env: Env): void; // called once per tab
export function getMeter(name: string): Meter; // thin re-export of @opentelemetry/api
export function getTracer(name: string): Tracer;
```
- **`src/observability/metrics/custom.ts`** — minimum-set custom metrics from design spec §7.3 as exported module-level instruments. **This pattern is safe** because the instruments are created against `@opentelemetry/api`'s proxy meter, which lazy-resolves to the real meter after `initServerOtel`/`initBrowserOtel` runs:
```ts
import { metrics } from "@opentelemetry/api";
const meter = metrics.getMeter("flights");
export const flightsSsrRequestDuration = meter.createHistogram("flights.ssr.request.duration");
export const flightsApiRequestDuration = meter.createHistogram("flights.api.request.duration");
export const flightsApiError = meter.createCounter("flights.api.error");
export const flightsSignalRConnected = meter.createUpDownCounter("flights.signalr.connected");
export const flightsSignalRMessageReceived = meter.createCounter("flights.signalr.message.received");
export const flightsSignalRDisconnect = meter.createCounter("flights.signalr.disconnect");
export const flightsFeatureRender = meter.createCounter("flights.feature.render");
export const flightsReactError = meter.createCounter("flights.react.error");
// web-vitals histograms created at init time inside initBrowserOtel, not exported statically
```
**Shared file ownership flag:** `modern.config.ts` (owned by 1A-2) — 1G-metrics modifies it to wire OTel SDK init + request tracing plugin into the Modern.js middleware chain.
**Exit gate for 1G-metrics:**
- Integration test: `initServerOtel` runs, a counter is incremented, and the test reader observes the recorded value (proves the proxy meter resolved correctly).
- ESLint rule from 1A-3 blocks a fabricated `import { MeterProvider } from "@opentelemetry/sdk-metrics"` in a file outside `otel.ts`.
---
### 1G-analytics — Analytics facade contracts
**Exports:**
- **`src/observability/analytics/types.ts`**:
```ts
export interface AnalyticsProviders {
metrica: boolean;
ctm: boolean;
variocube: boolean;
dynatrace: boolean;
}
export interface AnalyticsProps { [k: string]: unknown; }
export interface AnalyticsEvent {
kind: "track" | "page";
name: string; // event name or page URL
props: AnalyticsProps;
provider: string; // "metrica" | "ctm" | "variocube" | "dynatrace"
ts: string;
}
export interface Analytics {
track(event: string, props?: AnalyticsProps): void;
page(url: string, props?: AnalyticsProps): void;
}
```
- **`src/observability/analytics/facade.ts`**:
```ts
export function createAnalytics(options: {
enabled: AnalyticsProviders;
consent: { analytics: boolean; telemetry: boolean };
logger: Logger;
}): Analytics;
```
- **`src/observability/analytics/adapters/{metrica,ctm,variocube,dynatrace}.ts`** — four adapters implementing:
```ts
export interface AnalyticsAdapter {
name: string;
load(): Promise<void>;
track(event: string, props?: AnalyticsProps): void;
page(url: string, props?: AnalyticsProps): void;
}
```
**Phase 1 ships these as structured stubs** (R8): each stub's `load()`/`track()`/`page()` emits an `AnalyticsEvent` to a **test-observable sink** (`src/observability/analytics/sink.ts`) with the `provider` field set to the adapter's name. Real vendor scripts wire in Phase 2A (alongside the Online Board migration) after A7 resolves.
- **`src/observability/analytics/sink.ts`** — test-observable event sink; exports `getRecordedEvents()` and `resetEvents()` for integration tests. In production, the sink is a no-op ring buffer.
- **`src/observability/analytics/loader.tsx`** — `<AnalyticsLoader>` component that mounts in the root layout (owned by 1F-layout, modified by 1G-analytics to add the mount). Waits for `requestIdleCallback`, then imports enabled adapters and calls `.load()`.
- **`src/observability/analytics/provider.tsx`** — React context + `useAnalytics()` hook. Server: returns a `NoopAnalytics`. Client: returns the instance from `<AnalyticsLoader>`.
**Exit gate for 1G-analytics:**
- Integration test: smoke route calls `track("smoke.pageview")`, all four stub adapters emit exactly one `AnalyticsEvent` to the sink with matching props.
- `consent.analytics = false` short-circuits before any adapter is invoked.
- Adapter load failure emits `flights.analytics.load_failed` counter.
---
### 1H — Security hardening contracts
**Exports:**
- **`src/server/middleware/csp.ts`** — Modern.js middleware that generates a per-request nonce, sets the `Content-Security-Policy` header (per design spec §8.1), and exposes the nonce to the React render tree via a request-scoped context.
```ts
export function cspMiddleware(options: { reportOnly?: boolean }): ModernMiddleware;
export const CspNonceContext: React.Context<string>; // default ""; components reading client-side no-op on empty
```
- **`src/server/middleware/nonce-stream-transform.ts`** — **workaround for React issue #24883.** React 18's `renderToPipeableStream({ nonce })` only applies the nonce to inline `bootstrapScriptContent`, **not to external `bootstrapScripts` src URLs.** This middleware post-processes the SSR HTML stream to inject `nonce="{nonce}"` on every `<script>` tag that doesn't already have one.
```ts
export function wrapSsrStreamWithNonce(
stream: NodeJS.ReadableStream,
nonce: string,
): NodeJS.ReadableStream;
```
Integration test (required for the exit gate): every `<script>` tag in the SSR output of `/ru/smoke` carries the per-request nonce.
- **`src/server/middleware/security-headers.ts`** — Modern.js middleware setting HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy (per design spec §8.1).
- **`src/shared/storage.ts`** — the only module allowed to touch `window.localStorage` / `window.sessionStorage` (enforced by 1A-3). Namespaced keys + Zod schema validation on read:
```ts
import type { ZodSchema } from "zod";
export const storage = {
get<T>(key: string, schema: ZodSchema<T>): T | null,
set<T>(key: string, value: T, schema: ZodSchema<T>): void,
delete(key: string): void,
clear(): void,
};
```
**Shared file ownership flags:**
- `modern.config.ts` (owned by 1A-2) — 1H modifies to register CSP, nonce-stream-transform, and security-headers middlewares.
- `src/routes/layout.tsx` (owned by 1F-layout) — 1H modifies to propagate the CSP nonce into `<Head>`-emitted inline scripts.
**Exit gate for 1H:**
- SSR render emits a CSP header with a unique nonce per request; **every** `<script>` in the output (both inline and external `bootstrapScripts`) carries the nonce.
- `storage.get` with a mismatching schema returns `null` rather than corrupt data.
- ESLint: `window.localStorage` usage outside `src/shared/storage.ts` fails lint (rule from 1A-3).
---
### 1I — Deploy pipeline + runbook contracts
**Exports:**
- **`Dockerfile`** at repo root — multi-stage build: **Node 24** base, installs pnpm, runs `pnpm build:standalone`, produces a minimal production image with the standalone server as the entrypoint.
- **`Dockerfile.remote`** — static-file server image for the remote-mode artifact (nginx base, copies `dist/remote/` into `/usr/share/nginx/html`).
- **`src/server/routes/health.ts`** — health endpoint registered at `/health` via Modern.js's middleware API:
```ts
export function healthMiddleware(options: {
apiClient: ApiClient;
upstreamTimeoutMs?: number;
}): ModernMiddleware;
```
Returns 200 if the last successful upstream REST ping is within 60s, 503 otherwise.
- **`src/server/shutdown.ts`** — graceful shutdown handler: on SIGTERM, stops accepting new requests, drains in-flight for 30s, flushes the log buffer, exits.
- **`.github/workflows/deploy.yml`** — reuses PR-built artifacts, builds Docker images, pushes to the customer's registry, triggers a canary deploy to the `testing` env via the customer's deployment tool, runs post-deploy smoke test, auto-rolls back on health-check failure.
- **`docs/superpowers/phase-1/runbook.md`** — operational runbook (requirements gap R1) covering:
1. Incident response decision tree (user impact → severity → who pages whom)
2. Canary rollout procedure with monitoring checklist
3. Rollback procedure (auto and manual)
4. Health-check interpretation (`/health` 200/503 meanings, common causes)
5. Log query cookbook (how to find errors in the customer log aggregator)
6. Known-failure playbooks for: upstream API down, SignalR hub offline, CSP violation spike, analytics adapter load failure, OTel exporter unreachable.
**Shared file ownership flag:** `modern.config.ts` (owned by 1A-2) — 1I modifies to register the health middleware and the graceful shutdown hook.
**Exit gate for 1I:**
- Merge to `main` triggers the deploy workflow; `testing` env shows a new revision; `/health` returns 200.
- SIGTERM drains in-flight and exits 0 within 31s.
- Forced health-check failure auto-rolls back.
- Runbook reviewed by at least one engineer and one ops-adjacent reader; 6-hour recovery SLA walked through step-by-step.
---
## Shared files — cross-sub-plan modification table
| File | Primary owner | Also modified by | What the modifiers add |
|---|---|---|---|
| `modern.config.ts` | 1A-2 | 1G-metrics | OTel SDK init + request tracing plugin |
| | | 1H | CSP + nonce-stream-transform + security-headers middleware |
| | | 1I | Health middleware + graceful shutdown hook |
| `src/routes/layout.tsx` | 1F-layout | 1G-logger | `<LoggerProvider>` wrap |
| | | 1G-analytics | `<AnalyticsLoader>` mount |
| | | 1H | CSP nonce propagation into `<Head>` inline scripts |
| `src/routes/[lang]/layout.tsx` | 1F-layout | 1G-logger | Per-request logger child creation |
| | | 1G-metrics | OTel span attachment |
| `.eslintrc.cjs` | 1A-1 (base) | 1A-3 | Boundaries plugin rules + `no-restricted-imports` rules |
| `package.json` | 1A-1 | 1A-2 | `@module-federation/modern-js`, Modern.js, Rspack (pinned from spike) |
| | | 1B | CI-related dev deps (`@vitest/coverage-v8`, size-limit, osv-scanner wrappers) |
| | | 1C | `i18next`, `react-i18next`, `i18next-icu`, `i18next-resources-to-backend` |
| | | 1D | `lru-cache@^10`, `undici` (pinned) |
| | | 1E | `@microsoft/signalr` |
| | | 1F-seo | `schema-dts` |
| | | 1G-logger | — (stdlib only) |
| | | 1G-metrics | `@opentelemetry/api`, `@opentelemetry/sdk-node`, `@opentelemetry/sdk-metrics`, `@opentelemetry/sdk-web`, `@opentelemetry/exporter-trace-otlp-http`, `@opentelemetry/exporter-metrics-otlp-http`, `web-vitals` |
| | | 1G-analytics | — (stubs only in Phase 1) |
| | | 1I | — (Node 24 native `undici`; additional runtime deps TBD) |
| `tsconfig.json` | 1A-1 | — | No modifications after 1A-1 |
**Modification protocol.** When a downstream sub-plan modifies a file owned by another, its task must:
1. Explicitly reference the primary owner ("modifying `modern.config.ts`, which was created in 1A-2 Task N").
2. Quote the expected pre-modification state of the file (from the primary owner's exit gate).
3. Show the full post-modification file, not just a diff.
4. Re-run the primary owner's exit-gate test to prove the modification didn't break it.
---
## Spec-coverage matrix
| Spec section | Topic | Sub-plan(s) |
|---|---|---|
| §1.1, §1.2 | Runtime topology, dependency direction | 1A-1 + 1A-3 |
| §1.3 | `src/` tree | 1A-1 |
| §1.4 | URL language-prefix policy | 1C + 1F-layout |
| §2.1 | Two build targets | 1A-2 |
| §2.2 | `mf-manifest.json` + exposed modules | 1A-2 |
| §2.3 | Shared dependencies (React singleton) | 1A-2 |
| §2.4 | `HostContract` | 1A-1 (type defined; `RemoteLoader` consumer path in 1A-2) |
| §2.5 | Build artifacts + deploy shape | 1A-2 + 1I |
| §3.1 | SSR request lifecycle | 1F-layout + 1G-logger + 1G-metrics + 1H |
| §3.2 | Two SSR invariants | 1A-3 (ESLint) + 1E (client-only dynamic import) |
| §3.3 | File-based routing | 1F-layout |
| §3.4 | Loaders, Suspense, `React.lazy` | 1F-layout (smoke route demonstrates the pattern) |
| §3.5 | URL parity — ported serializers | Phase 2 (deferred with 1J) |
| §3.6 | Canonical / hreflang / redirects | 1F-seo + 1F-layout |
| §4.1 | REST client | 1D |
| §4.2 | Caching strategy (three caches, `lru-cache@^10` byte cap) | 1D |
| §4.3 | SSR → client data handoff | 1F-layout (smoke route demonstrates pattern) |
| §4.4 | SignalR wrapper + hook | 1E |
| §4.5 | State management | (no Phase 1 work) |
| §4.6 | Error handling path | 1D (`ApiError` types) + 1F-layout (`errorToResponse` + `ErrorBoundary`) |
| §5 | UI adapter + styling | (Phase 2) |
| §6.1–§6.4 | i18n | 1C |
| §6.5 | `<SeoHead>` | 1F-seo |
| §6.6 | JSON-LD schema coverage | 1F-seo (`JsonLdRenderer` helper; feature-specific builders in Phase 2+) |
| §6.7 | OG images (static default) | 1F-seo |
| §6.8 | Canonical/hreflang correctness | 1F-seo + 1F-layout |
| §7.1, §7.2 | Logging | 1G-logger |
| §7.3 | Metrics | 1G-metrics |
| §7.4 | Analytics | 1G-analytics |
| §7.5 | Error handling layers | 1F-layout + 1G-logger + 1G-metrics |
| §7.6 | Correlation | 1G-metrics (trace IDs) + 1G-logger (propagation) |
| §7.7 | Performance budgets | 1B (bundle gate) + 1G-metrics (web-vitals reporting) |
| §7.8 | Dev-mode ergonomics | 1G-logger (console transport) |
| §8.1 | Security / isolation | 1H |
| §8.2 | Performance / 100 RPS | 1A-1 (Node 24 tuning) + 1B (bundle gate) + 1I (CDN headers) |
| §8.3 | Reliability / geo-distribution | 1I |
| §8.4 | Testing strategy | 1B (parity harnesses deferred to Phase 2) |
| §8.5 | CI/CD | 1B + 1I |
---
## Phase 1 global exit gate — checklist
- [ ] **1A-1:** `pnpm typecheck` + `pnpm lint` green on baseline src tree; `src/env/` Zod validation passes; frozen-barrel rule documented; `HostContract` type in place.
- [ ] **1A-2:** MF spike doc + pinned version matrix committed; `pnpm build:both` produces valid `dist/standalone/` + `dist/remote/` + `mf-manifest.json`; `loadRemoteModule` integration test green.
- [ ] **1A-3:** Every ESLint boundary + restricted-import rule has a passing fabricated-violation test.
- [ ] **1B:** Per-PR CI runs green under 20 min. Nightly pipeline runs successfully at least once.
- [ ] **1C:** 9 locale JSON files in place; i18n SSR + hydration roundtrip test passes; zero missing-key warnings on the smoke route.
- [ ] **1D:** `ApiClient` unit tests green (retry via `undici.RetryAgent`, timeout, `Retry-After`, circuit breaker, three caches including 100MB byte-cap LRU); isomorphic usage verified SSR + client.
- [ ] **1E:** SignalR wrapper tests green (Strict Mode double-mount, grace-period close, SSR import absence); `useLiveFlights<TParams, TData>` generic signature verified by a typed test consumer.
- [ ] **1F-layout:** `/ru/smoke` + `/en/smoke` render SSR in `testing` env with full `<head>`; `errorToResponse` mapper round-trips all five error shapes; **responsive baseline** passes at 320/768/1280/1920.
- [ ] **1F-seo:** `buildHreflangSet` covers 9 langs + `x-default`; `JsonLdRenderer` round-trips a typed `Thing`.
- [ ] **1G-logger:** Type-only file shipped first (consumed by 1A-1); batching + flush + redaction + `child()` tests green; A4-trigger task documented.
- [ ] **1G-metrics:** OTel init + custom instruments emit through proxy meter; ESLint guard on `@opentelemetry/sdk-metrics` proven.
- [ ] **1G-analytics:** Four stub adapters fan out to structured sink; consent short-circuit verified; smoke route emits one log + one metric + one analytics event, observed at the sink.
- [ ] **1H:** CSP header on `testing` env contains per-request nonce; **every** `<script>` tag (inline + external) carries the nonce via the stream transform; security headers present on every response; storage schema validation verified.
- [ ] **1I:** Merge to `main` auto-deploys to `testing` via canary; `/health` returns 200; SIGTERM drains within 31s; forced health failure auto-rolls back; **runbook** reviewed and 6-hour SLA walked through.
- [ ] **Security scan:** `osv-scanner` + `npm audit` green on `main`.
- [ ] **Bundle-size gate:** all Phase 1 budgets from design spec §8.2 met on the smoke route.
- [ ] **Coverage:** ≥ 70% line coverage on `src/shared/`, `src/observability/`, `src/ui/`, `src/i18n/`.
- [ ] **Documentation:** `docs/superpowers/phase-1/README.md` indexes what Phase 1 shipped + how to run each subsystem locally + how to debug failures + pointer to the runbook.
---
## Risks + open questions for Phase 1
1. **Customer template (A1) still unknown.** If it arrives during Phase 1, 1A-1's rename-pass rework task executes. Frozen-barrel rule keeps the rename bounded.
2. **Customer log format (A4) still unknown.** 1G-logger ships `JsonLinesHttpTransport`. A4-trigger task creates `CustomerFormatTransport` when the format arrives. No consumer changes.
3. **Analytics vendor credentials (A7) still unknown.** 1G-analytics ships structured stubs. Real scripts land in Phase 2A.
4. **CI provider (A3) is a hard blocker for 1A-1.** Assumed GitHub Actions in the master plan; if the customer uses GitLab CI or TeamCity, 1B's workflow YAML is rewritten but the underlying scripts are portable.
5. **Modern.js + MF 2.0 interaction (T1).** De-risked by the 1A-2 spike as its first task — escalation path defined if the spike fails.
6. **Node 24 availability on customer VMs (A9, new).** Hard blocker for 1A-1. Node 20 LTS ends April 2026; running Phase 1 on Node 20 would bake in an immediate upgrade debt.
7. **React issue #24883 (CSP nonce gap on bootstrapScripts).** Known React limitation. 1H ships a stream-transform workaround with an integration test; if React fixes this upstream before Phase 1 exits, the workaround can be removed in 1H's follow-up.
8. **`lru-cache@^10` byte-sizing cost.** `sizeCalculation` runs on every `set()`. For 100MB cap with `JSON.stringify(value).length` as the default sizer, this is O(n) per cache write. Mitigation: pass a custom `sizeCalculation` that uses pre-computed size hints from the API response (e.g., `content-length` header) where available.
9. **SSR + `<Suspense>` + streaming + React 18 concurrent mode** has known interop issues with some libraries. 1F-layout's smoke route uses `React.lazy()` + `<Suspense>` + a data loader as a deliberate stress test.
---
## How to write each sub-plan
When the user is ready to execute a sub-plan, re-invoke `superpowers:writing-plans` with a specific prompt like:
> "Write sub-plan 1A-1 (project skeleton) from `docs/superpowers/plans/2026-04-14-phase-1-foundation-master.md`. Target file: `docs/superpowers/plans/2026-04-14-phase-1a1-skeleton.md`. Follow the contracts defined in the master plan §1A-1 exactly; reference the design spec §1.3, §2.1 as source material."
The sub-plan writer must:
1. Read this master plan in full for the dependency + contract context.
2. Read the relevant design spec sections.
3. Read any upstream sub-plans that have already been written (their exit gates lock in file/API shapes).
4. Produce a fully TDD-granular plan at the shape of `docs/superpowers/plans/2026-04-14-phase-0-preflight.md`.
5. Match the contracts in this master plan byte-for-byte on type signatures. Any contract change requires updating this master plan first.
---
## Self-review
**Spec coverage.** Every design-spec section §1–§8 maps to at least one sub-plan in the spec-coverage matrix. §9 (Phase 26 migration) and parity harnesses (§3.5, §8.4) are explicitly deferred to Phase 2.
**Placeholder scan.** No `TBD` / `TODO` / `FIXME` outside of the "TBW" markers on sub-plan filenames (deliberate indicators of un-written sub-plans).
**Internal consistency.** Cross-checked: `Logger` extracted as type-only file in 1G-logger's first task → used by 1A-1, 1D, 1F-layout; `Language` in 1C → used by 1F-seo, 1F-layout; `ApiClient` + caches in 1D → used by 1F-layout, 1I; `ConnectionStatus` in 1E → Phase 2 consumer; `AnalyticsProviders` in 1G-analytics → used by `Env` in 1A-1 (via type-only import from `src/observability/analytics/types`).
**Plan-order cycle resolution.** The `HostContract` → `Logger` dependency between 1A-1 and 1G-logger is resolved by 1G-logger shipping `src/observability/logger/types.ts` as its first task. The `Env` → `AnalyticsProviders` dependency is resolved by 1G-analytics owning `src/observability/analytics/types.ts` as a type-only file that 1A-1 imports.
**Execution order correctness.** The dependency graph has no cycles after the type-only extractions. Critical path: 1A-1 → 1A-2 → 1A-3 → 1C → 1F-layout → 1H → 1I (seven serial sub-plans with one engineer).
---
## Next step
- **If you approve this master plan:** say so, and I'll write sub-plan **1A-1** (project skeleton) fully in the next session. Then we iterate.
- **If you want changes:** tell me, I revise.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,467 +0,0 @@
# Phase 1A-3 — ESLint Boundaries + Restricted Imports Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `eslint-plugin-boundaries` layered dependency rules and `no-restricted-imports` guards to the existing ESLint flat config so that architectural violations are caught at lint time — preventing features from importing routes, UI from importing features, observability from leaking SDK internals, and storage from bypassing the `src/shared/storage.ts` wrapper.
**Architecture:** Extends `eslint.config.js` from 1A-1 with two new plugins/rules: `eslint-plugin-boundaries` (defines element types by directory pattern, enforces which layers can import which) and built-in `no-restricted-imports` (bans specific package imports outside their designated "owner" file). Each rule has a fabricated-violation test in `tests/eslint/` that asserts the rule fires.
**Tech Stack:** `eslint-plugin-boundaries@^5.0.0` (ESLint 9 flat-config compatible), ESLint built-in `no-restricted-imports`.
**Scope:** Config-only. No runtime code, no tests of production logic. Every task is "add a rule + add a probe test that proves it fires."
**Prerequisites (1A-1 + 1A-2 complete):**
- `eslint.config.js` flat config with `typescript-eslint`, `unused-imports` already configured.
- `src/` tree with `features/`, `ui/`, `shared/`, `observability/`, `mf/`, `routes/`, `env/` directories populated.
---
## File structure
| File | Responsibility | Task |
|---|---|---|
| `eslint.config.js` | Modify: add boundaries plugin + no-restricted-imports rules | 1, 2 |
| `tests/eslint/boundaries.test.ts` | Fabricated violation tests for boundary rules | 3 |
| `tests/eslint/restricted-imports.test.ts` | Fabricated violation tests for restricted imports | 4 |
---
## Task 1 — Install `eslint-plugin-boundaries` and configure element types
**Files:**
- Modify: `package.json` (devDep)
- Modify: `eslint.config.js`
- [ ] **Step 1: Install the plugin**
```bash
pnpm add -D eslint-plugin-boundaries@^5.0.0
```
- [ ] **Step 2: Add boundary element types and dependency rules to `eslint.config.js`**
Add the following to `eslint.config.js` — import at the top, new config object appended to the array:
At the top of the file, add the import:
```javascript
import boundaries from "eslint-plugin-boundaries";
```
Then add a new config object to the exported array (after the existing `files: ["src/**/*.{ts,tsx}"]` block):
```javascript
{
files: ["src/**/*.{ts,tsx}"],
plugins: {
boundaries,
},
settings: {
"boundaries/elements": [
{ type: "routes", pattern: "src/routes/*" },
{ type: "mf", pattern: "src/mf/*" },
{ type: "features", pattern: "src/features/*", capture: ["feature"] },
{ type: "ui", pattern: "src/ui/*" },
{ type: "shared", pattern: "src/shared/*" },
{ type: "observability", pattern: "src/observability/*" },
{ type: "i18n", pattern: "src/i18n/*" },
{ type: "env", pattern: "src/env/*" },
],
},
rules: {
// Design spec §1.2 layered dependency direction:
// features/ cannot import routes/ or mf/
// ui/ cannot import features/
// shared/ cannot import features/, routes/, mf/, observability/
// observability/ cannot import features/, routes/, mf/
"boundaries/element-types": [
"error",
{
default: "allow",
rules: [
{
from: "features",
disallow: ["routes", "mf"],
message: "Features must not import from routes/ or mf/. Use the HostContract or a shared module instead.",
},
{
from: "ui",
disallow: ["features", "routes", "mf"],
message: "UI layer must not import from features/, routes/, or mf/. UI is consumed by features, not the other way around.",
},
{
from: "shared",
disallow: ["features", "routes", "mf", "observability"],
message: "Shared modules must not import from features/, routes/, mf/, or observability/.",
},
{
from: "observability",
disallow: ["features", "routes", "mf"],
message: "Observability modules must not import from features/, routes/, or mf/.",
},
],
},
],
},
},
```
- [ ] **Step 3: Verify lint still passes on existing code**
```bash
pnpm lint
```
Expected: exit 0 — existing code doesn't violate the boundaries because no cross-layer imports exist yet.
- [ ] **Step 4: Commit**
```bash
git add package.json pnpm-lock.yaml eslint.config.js
git commit -m "Add eslint-plugin-boundaries with layered dependency rules"
```
---
## Task 2 — Add `no-restricted-imports` rules
**Files:**
- Modify: `eslint.config.js`
Four restricted-import rules per master plan §1A-3:
1. `@opentelemetry/sdk-metrics` — only in `src/observability/metrics/otel.ts`
2. `window.localStorage` / `window.sessionStorage` — only in `src/shared/storage.ts` (via `no-restricted-globals`)
3. `@microsoft/signalr` — forbidden in SSR-bundle files (enforced by file-path pattern)
4. `react-i18next` — only in `src/i18n/provider.tsx`
- [ ] **Step 1: Add restricted-imports rules to `eslint.config.js`**
Add a new config object to the array — these are file-path-scoped overrides:
```javascript
// --- Restricted imports (master plan §1A-3) ---
// OTel SDK internals: only src/observability/metrics/otel.ts may import them
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/observability/metrics/otel.ts"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@opentelemetry/sdk-metrics",
message: "Import from @opentelemetry/api or use getMeter() from @/observability/metrics/otel instead. Direct SDK access is restricted to src/observability/metrics/otel.ts.",
},
{
name: "@opentelemetry/sdk-node",
message: "Import from @opentelemetry/api or use getMeter()/getTracer() from @/observability/metrics/otel instead. Direct SDK access is restricted to src/observability/metrics/otel.ts.",
},
],
},
],
},
},
// react-i18next: only src/i18n/provider.tsx may import it
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/i18n/provider.tsx"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "react-i18next",
message: "Import useTranslation from @/i18n/provider instead. Direct react-i18next imports are restricted to src/i18n/provider.tsx.",
},
],
},
],
},
},
// @microsoft/signalr: forbidden in files that run during SSR.
// SSR-bundle = routes/ and server/ directories. Features + shared/hooks are client-side.
{
files: ["src/routes/**/*.{ts,tsx}", "src/server/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@microsoft/signalr",
message: "SignalR must not be imported in SSR-bundle files (routes/, server/). Use dynamic import in a useEffect or a client-only wrapper.",
},
],
},
],
},
},
// window.localStorage / window.sessionStorage: only src/shared/storage.ts
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/shared/storage.ts"],
rules: {
"no-restricted-globals": [
"error",
{
name: "localStorage",
message: "Use the storage module from @/shared/storage instead. Direct localStorage access is restricted to src/shared/storage.ts.",
},
{
name: "sessionStorage",
message: "Use the storage module from @/shared/storage instead. Direct sessionStorage access is restricted to src/shared/storage.ts.",
},
],
},
},
```
- [ ] **Step 2: Verify lint still passes**
```bash
pnpm lint
```
Expected: exit 0 — no existing file imports these restricted packages.
- [ ] **Step 3: Commit**
```bash
git add eslint.config.js
git commit -m "Add no-restricted-imports for OTel SDK, react-i18next, SignalR SSR, localStorage"
```
---
## Task 3 — Fabricated violation tests for boundary rules
**Files:**
- Create: `tests/eslint/boundaries.test.ts`
These tests create temporary probe files, run `eslint` on them, and assert violations. This is NOT a vitest test — it's a shell-command test that invokes ESLint on fabricated files. But we wrap it in vitest for consistency with the test runner.
- [ ] **Step 1: Write `tests/eslint/boundaries.test.ts`**
```typescript
import { describe, expect, it } from "vitest";
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const ROOT = path.resolve(import.meta.dirname, "../..");
function lintString(filePath: string, content: string): string {
const absPath = path.join(ROOT, filePath);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, content, "utf8");
try {
execSync(`pnpm exec eslint "${absPath}" --no-eslintrc -c eslint.config.js --format json`, {
cwd: ROOT,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
});
return "PASS";
} catch (err: unknown) {
const error = err as { stdout?: string };
return error.stdout ?? "UNKNOWN_ERROR";
} finally {
fs.unlinkSync(absPath);
}
}
describe("boundaries rules", () => {
it("features/ cannot import from routes/", () => {
const result = lintString(
"src/features/online-board/__test_probe__.ts",
'import { something } from "../../routes/page";\nexport const x = something;\n',
);
expect(result).toContain("boundaries/element-types");
});
it("features/ cannot import from mf/", () => {
const result = lintString(
"src/features/online-board/__test_probe__.ts",
'import { REMOTE_BUILD_MARKER } from "../../mf/host-entry";\nexport const x = REMOTE_BUILD_MARKER;\n',
);
expect(result).toContain("boundaries/element-types");
});
it("ui/ cannot import from features/", () => {
const result = lintString(
"src/ui/__test_probe__.ts",
'import { something } from "../features/online-board";\nexport const x = something;\n',
);
expect(result).toContain("boundaries/element-types");
});
it("shared/ cannot import from features/", () => {
const result = lintString(
"src/shared/__test_probe__.ts",
'import { something } from "../features/online-board";\nexport const x = something;\n',
);
expect(result).toContain("boundaries/element-types");
});
it("observability/ cannot import from features/", () => {
const result = lintString(
"src/observability/__test_probe__.ts",
'import { something } from "../features/online-board";\nexport const x = something;\n',
);
expect(result).toContain("boundaries/element-types");
});
it("features/ CAN import from shared/ (allowed direction)", () => {
// This should NOT trigger a boundary violation.
// We can't fully test this without a real shared module, so just verify
// the rule doesn't fire on a features→env import (which is allowed).
const result = lintString(
"src/features/online-board/__test_probe__.ts",
'import { getEnv } from "../../env";\nexport const x = getEnv;\n',
);
expect(result).not.toContain("boundaries/element-types");
});
});
```
- [ ] **Step 2: Update vitest config to include `tests/` directory**
The current `vitest.config.ts` has `include: ["src/**/*.test.ts", "src/**/*.test.tsx"]`. Add `tests/` to the include:
Edit `vitest.config.ts` `include` to:
```typescript
include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts"],
```
- [ ] **Step 3: Run the boundary tests**
```bash
pnpm test tests/eslint/boundaries
```
Expected: all 6 tests pass. The first 5 should show "boundaries/element-types" in the ESLint output; the last one should NOT.
If tests fail because `eslint-plugin-boundaries` doesn't detect imports via relative paths, the plugin may need the `boundaries/include` setting or file resolution config. Debug by running the lint command manually on a probe file and checking what ESLint reports.
If the probe tests are too slow (each spawns an ESLint process), that's acceptable — these are CI-only correctness checks, not hot-loop unit tests.
- [ ] **Step 4: Commit**
```bash
git add tests/eslint/boundaries.test.ts vitest.config.ts
git commit -m "Add fabricated violation tests for boundary rules"
```
---
## Task 4 — Fabricated violation tests for restricted imports
**Files:**
- Create: `tests/eslint/restricted-imports.test.ts`
- [ ] **Step 1: Write `tests/eslint/restricted-imports.test.ts`**
```typescript
import { describe, expect, it } from "vitest";
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const ROOT = path.resolve(import.meta.dirname, "../..");
function lintString(filePath: string, content: string): string {
const absPath = path.join(ROOT, filePath);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, content, "utf8");
try {
execSync(`pnpm exec eslint "${absPath}" --no-eslintrc -c eslint.config.js --format json`, {
cwd: ROOT,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
});
return "PASS";
} catch (err: unknown) {
const error = err as { stdout?: string };
return error.stdout ?? "UNKNOWN_ERROR";
} finally {
fs.unlinkSync(absPath);
}
}
describe("no-restricted-imports rules", () => {
it("blocks @opentelemetry/sdk-metrics outside otel.ts", () => {
const result = lintString(
"src/features/online-board/__test_probe__.ts",
'import { MeterProvider } from "@opentelemetry/sdk-metrics";\nexport const x = MeterProvider;\n',
);
expect(result).toContain("no-restricted-imports");
});
it("blocks react-i18next outside provider.tsx", () => {
const result = lintString(
"src/features/online-board/__test_probe__.ts",
'import { useTranslation } from "react-i18next";\nexport const x = useTranslation;\n',
);
expect(result).toContain("no-restricted-imports");
});
it("blocks @microsoft/signalr in routes/ (SSR bundle)", () => {
const result = lintString(
"src/routes/__test_probe__.ts",
'import { HubConnectionBuilder } from "@microsoft/signalr";\nexport const x = HubConnectionBuilder;\n',
);
expect(result).toContain("no-restricted-imports");
});
it("blocks localStorage outside storage.ts", () => {
const result = lintString(
"src/features/online-board/__test_probe__.ts",
'const x = localStorage.getItem("key");\nexport default x;\n',
);
expect(result).toContain("no-restricted-globals");
});
});
```
- [ ] **Step 2: Run the restricted-import tests**
```bash
pnpm test tests/eslint/restricted-imports
```
Expected: all 4 tests pass.
- [ ] **Step 3: Commit**
```bash
git add tests/eslint/restricted-imports.test.ts
git commit -m "Add fabricated violation tests for restricted import rules"
```
---
## Task 5 — Full exit-gate verification
- [ ] **Step 1: Quality gates**
```bash
pnpm typecheck && pnpm lint && pnpm test
```
Expected: typecheck exit 0, lint exit 0, all tests pass (11 existing + 10 new = 21 total).
- [ ] **Step 2: Verify git status**
```bash
git status
```
Expected: clean working tree.
---
## Self-review
**Spec coverage.** Master plan §1A-3 exports:
- `eslint-plugin-boundaries` rules for layered deps → Task 1
- `no-restricted-imports`: OTel SDK, react-i18next, @microsoft/signalr SSR, localStorage → Task 2
- Fabricated violation tests → Tasks 3, 4
**Placeholder scan.** No TBD/TODO. All rule configs are concrete.
**Type consistency.** Element type names (`routes`, `mf`, `features`, `ui`, `shared`, `observability`, `i18n`, `env`) match the `src/` directory names.
@@ -1,678 +0,0 @@
# Phase 1C — i18n Runtime + Locale Port Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the i18n runtime — `i18next` configured with ICU support, a URL locale resolver, 9 ported locale JSON files, SSR↔client hydration helpers, and a React context provider — so that downstream sub-plans (1F-layout, Phase 2 features) can render localized text with `t("BOARD.DEPARTURE")` in both SSR and client contexts without re-fetching locale data on hydration.
**Architecture:** `i18next` with `i18next-icu` as the ICU MessageFormat backend. One namespace (`common`) per language. `src/i18n/resolver.ts` determines the locale from the URL prefix. `src/i18n/config.ts` creates a request-scoped `i18next` instance loaded with one locale's JSON. `src/i18n/serializer.ts` serializes the loaded bundle into the SSR HTML under `window.__I18N__` and rehydrates it client-side. `src/i18n/provider.tsx` wraps React context and re-exports `useTranslation` (the only approved way for feature code to access translations, enforced by 1A-3's `no-restricted-imports` rule on `react-i18next`).
**Tech Stack:** `i18next@^23`, `react-i18next@^15`, `i18next-icu@^2`, `i18next-resources-to-backend@^1`.
**Scope boundaries:**
- No feature-specific translation namespaces (single `common` namespace for Phase 1).
- No date-fns/tz porting — design spec §6.4 date formatting is Phase 2 work.
- No `<SeoHead>` — that's 1F-seo.
**Prerequisites:** 1A-1 + 1A-2 + 1A-3 complete. `src/env/`, `src/host-contract.ts`, ESLint boundaries all in place.
---
## File structure
| File | Responsibility | Task |
|---|---|---|
| `src/i18n/resolver.ts` | `Language` type, locale resolution from URL prefix | 2 |
| `src/i18n/resolver.test.ts` | Tests for resolver | 2 |
| `src/i18n/config.ts` | `createI18nInstance` factory | 3 |
| `src/i18n/config.test.ts` | Tests for factory | 3 |
| `src/i18n/serializer.ts` | SSR→client hydration helpers | 4 |
| `src/i18n/serializer.test.ts` | Roundtrip tests | 4 |
| `src/i18n/provider.tsx` | React context + `useTranslation` re-export | 5 |
| `src/i18n/locales/{lang}/common.json` (×9) | Ported locale bundles | 1 |
---
## Task 1 — Install i18n deps and port locale JSON files
**Files:**
- Modify: `package.json`
- Create: `src/i18n/locales/ru/common.json` (×9 languages)
- [ ] **Step 1: Install i18n packages**
```bash
pnpm add i18next@^23.0.0 react-i18next@^15.0.0 i18next-icu@^2.0.0 i18next-resources-to-backend@^1.0.0 intl-messageformat@^10.0.0
```
`intl-messageformat` is a peer dep of `i18next-icu`.
- [ ] **Step 2: Port locale JSON files**
Copy each Angular locale file to the new i18n directory. The file structure changes from flat (`ClientApp/src/assets/i18n/ru.json`) to nested (`src/i18n/locales/ru/common.json`) to support future per-feature namespaces.
```bash
mkdir -p src/i18n/locales/{ru,en,es,fr,it,ja,ko,zh,de}
for lang in ru en es fr it ja ko zh de; do
cp "ClientApp/src/assets/i18n/${lang}.json" "src/i18n/locales/${lang}/common.json"
done
```
Verify all 9 files copied:
```bash
ls -la src/i18n/locales/*/common.json | wc -l
```
Expected: 9.
**BOM removal.** The Angular files may have a UTF-8 BOM marker (`\xEF\xBB\xBF`). Strip it to avoid JSON parse issues:
```bash
for f in src/i18n/locales/*/common.json; do
sed -i '' '1s/^\xef\xbb\xbf//' "$f" 2>/dev/null || sed -i '1s/^\xef\xbb\xbf//' "$f"
done
```
Verify they parse:
```bash
for f in src/i18n/locales/*/common.json; do
node -e "JSON.parse(require('fs').readFileSync('$f','utf8'))" && echo "OK: $f"
done
```
Expected: 9 "OK" lines.
- [ ] **Step 3: Commit**
```bash
git add package.json pnpm-lock.yaml src/i18n/locales/
git commit -m "Install i18n deps and port 9 locale JSON files from Angular"
```
---
## Task 2 — TDD `src/i18n/resolver.ts`
**Files:**
- Create: `src/i18n/resolver.test.ts`
- Create: `src/i18n/resolver.ts`
**Contract (from master plan §1C):**
```ts
export type Language = "ru"|"en"|"es"|"fr"|"it"|"ja"|"ko"|"zh"|"de";
export const LANGUAGES: readonly Language[];
export function isLanguage(x: string): x is Language;
export function resolveLocaleFromPath(pathname: string): Language | null;
export function stripLocaleFromPath(pathname: string): { locale: Language; rest: string } | null;
```
- [ ] **Step 1: Write failing tests**
Create `src/i18n/resolver.test.ts`:
```typescript
import { describe, expect, it } from "vitest";
import {
type Language,
LANGUAGES,
isLanguage,
resolveLocaleFromPath,
stripLocaleFromPath,
} from "./resolver.js";
describe("LANGUAGES", () => {
it("contains exactly 9 supported languages", () => {
expect(LANGUAGES).toHaveLength(9);
expect(LANGUAGES).toContain("ru");
expect(LANGUAGES).toContain("en");
expect(LANGUAGES).toContain("de");
});
});
describe("isLanguage", () => {
it("returns true for valid languages", () => {
expect(isLanguage("ru")).toBe(true);
expect(isLanguage("en")).toBe(true);
expect(isLanguage("zh")).toBe(true);
});
it("returns false for invalid strings", () => {
expect(isLanguage("xx")).toBe(false);
expect(isLanguage("")).toBe(false);
expect(isLanguage("RU")).toBe(false);
expect(isLanguage("russian")).toBe(false);
});
});
describe("resolveLocaleFromPath", () => {
it("extracts locale from the first path segment", () => {
expect(resolveLocaleFromPath("/ru/onlineboard")).toBe("ru");
expect(resolveLocaleFromPath("/en/onlineboard/flight/SU100")).toBe("en");
expect(resolveLocaleFromPath("/de/schedule")).toBe("de");
});
it("returns null for paths without a valid locale prefix", () => {
expect(resolveLocaleFromPath("/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/xx/onlineboard")).toBeNull();
expect(resolveLocaleFromPath("/")).toBeNull();
expect(resolveLocaleFromPath("")).toBeNull();
});
it("handles bare locale path (e.g., /ru)", () => {
expect(resolveLocaleFromPath("/ru")).toBe("ru");
expect(resolveLocaleFromPath("/ru/")).toBe("ru");
});
});
describe("stripLocaleFromPath", () => {
it("strips locale and returns the rest", () => {
expect(stripLocaleFromPath("/ru/onlineboard")).toEqual({
locale: "ru",
rest: "/onlineboard",
});
expect(stripLocaleFromPath("/en/onlineboard/flight/SU100")).toEqual({
locale: "en",
rest: "/onlineboard/flight/SU100",
});
});
it("returns / as rest for bare locale path", () => {
expect(stripLocaleFromPath("/ru")).toEqual({ locale: "ru", rest: "/" });
expect(stripLocaleFromPath("/ru/")).toEqual({ locale: "ru", rest: "/" });
});
it("returns null for paths without a valid locale prefix", () => {
expect(stripLocaleFromPath("/onlineboard")).toBeNull();
expect(stripLocaleFromPath("/xx/schedule")).toBeNull();
});
});
```
- [ ] **Step 2: Run tests — MUST FAIL**
```bash
pnpm test src/i18n/resolver
```
Expected: FAIL — module not found.
- [ ] **Step 3: Write implementation**
Create `src/i18n/resolver.ts`:
```typescript
export type Language = "ru" | "en" | "es" | "fr" | "it" | "ja" | "ko" | "zh" | "de";
export const LANGUAGES: readonly Language[] = [
"ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de",
] as const;
const languageSet: ReadonlySet<string> = new Set(LANGUAGES);
export function isLanguage(x: string): x is Language {
return languageSet.has(x);
}
export function resolveLocaleFromPath(pathname: string): Language | null {
const segments = pathname.split("/").filter(Boolean);
const first = segments[0];
if (first !== undefined && isLanguage(first)) {
return first;
}
return null;
}
export function stripLocaleFromPath(
pathname: string,
): { locale: Language; rest: string } | null {
const locale = resolveLocaleFromPath(pathname);
if (locale === null) return null;
const rest = pathname.slice(`/${locale}`.length);
return {
locale,
rest: rest === "" || rest === "/" ? "/" : rest,
};
}
```
- [ ] **Step 4: Run tests — ALL MUST PASS**
```bash
pnpm test src/i18n/resolver
```
Expected: all tests pass.
- [ ] **Step 5: Typecheck + lint**
```bash
pnpm typecheck && pnpm lint
```
- [ ] **Step 6: Commit**
```bash
git add src/i18n/resolver.ts src/i18n/resolver.test.ts
git commit -m "Add locale resolver with Language type and URL prefix parsing"
```
---
## Task 3 — TDD `src/i18n/config.ts`
**Files:**
- Create: `src/i18n/config.test.ts`
- Create: `src/i18n/config.ts`
**Contract (from master plan §1C):**
```ts
export function createI18nInstance(options: {
locale: Language;
initialResources?: Record<string, Record<string, unknown>>;
}): Promise<i18n>;
```
- [ ] **Step 1: Write failing tests**
Create `src/i18n/config.test.ts`:
```typescript
import { describe, expect, it } from "vitest";
import { createI18nInstance } from "./config.js";
describe("createI18nInstance", () => {
it("creates an initialized i18next instance for the given locale", async () => {
const i18n = await createI18nInstance({ locale: "ru" });
expect(i18n.language).toBe("ru");
expect(i18n.isInitialized).toBe(true);
});
it("can translate a known key from the Russian locale", async () => {
const i18n = await createI18nInstance({ locale: "ru" });
const value = i18n.t("BOARD.DEPARTURE");
// The Russian file has "BOARD.DEPARTURE" = "Вылет"
expect(value).toBe("Вылет");
});
it("can translate a known key from the English locale", async () => {
const i18n = await createI18nInstance({ locale: "en" });
const value = i18n.t("BOARD.DEPARTURE");
expect(value).toBe("Departure");
});
it("uses initialResources instead of loading from filesystem when provided", async () => {
const i18n = await createI18nInstance({
locale: "ru",
initialResources: {
common: { TEST_KEY: "Тестовое значение" },
},
});
expect(i18n.t("TEST_KEY")).toBe("Тестовое значение");
});
it("returns the key path if a key is missing (no fallback to other locale)", async () => {
const i18n = await createI18nInstance({ locale: "ru" });
expect(i18n.t("NONEXISTENT.KEY")).toBe("NONEXISTENT.KEY");
});
it("each call returns a fresh instance (request-scoped)", async () => {
const a = await createI18nInstance({ locale: "ru" });
const b = await createI18nInstance({ locale: "en" });
expect(a).not.toBe(b);
expect(a.language).toBe("ru");
expect(b.language).toBe("en");
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
```bash
pnpm test src/i18n/config
```
- [ ] **Step 3: Write implementation**
Create `src/i18n/config.ts`:
```typescript
import i18next from "i18next";
import ICU from "i18next-icu";
import resourcesToBackend from "i18next-resources-to-backend";
import type { Language } from "./resolver.js";
export async function createI18nInstance(options: {
locale: Language;
initialResources?: Record<string, Record<string, unknown>>;
}): Promise<typeof i18next> {
const instance = i18next.createInstance();
const plugins = [ICU];
// If no pre-loaded resources, load from the locale JSON files on the filesystem.
if (!options.initialResources) {
plugins.push(
resourcesToBackend(
(language: string, namespace: string) =>
import(`./locales/${language}/${namespace}.json`),
),
);
}
for (const plugin of plugins) {
instance.use(plugin);
}
await instance.init({
lng: options.locale,
ns: ["common"],
defaultNS: "common",
fallbackLng: false,
interpolation: {
escapeValue: false,
},
keySeparator: ".",
...(options.initialResources
? {
resources: {
[options.locale]: options.initialResources,
},
}
: {}),
});
return instance;
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
```bash
pnpm test src/i18n/config
```
If tests fail because dynamic `import()` of `.json` files doesn't work in vitest's node environment, try setting `vitest.config.ts` to `environment: "node"` (already set) and ensure `resolveJsonModule: true` is in `tsconfig.json` (already set in 1A-1). If it still fails, use `fs.readFileSync` with `JSON.parse` as a fallback for the backend loader:
```typescript
resourcesToBackend(
(language: string, namespace: string) => {
const data = require(`./locales/${language}/${namespace}.json`);
return Promise.resolve(data);
},
),
```
- [ ] **Step 5: Typecheck + lint**
```bash
pnpm typecheck && pnpm lint
```
- [ ] **Step 6: Commit**
```bash
git add src/i18n/config.ts src/i18n/config.test.ts
git commit -m "Add createI18nInstance factory with ICU and resource backend"
```
---
## Task 4 — TDD `src/i18n/serializer.ts`
**Files:**
- Create: `src/i18n/serializer.test.ts`
- Create: `src/i18n/serializer.ts`
**Contract (from master plan §1C):**
```ts
export function serializeI18nForHydration(i18n: i18n): string; // emits JSON string
export function hydrateI18nFromWindow(): Promise<i18n>; // reads window.__I18N__
```
- [ ] **Step 1: Write failing tests**
Create `src/i18n/serializer.test.ts`:
```typescript
import { afterEach, describe, expect, it, vi } from "vitest";
import { createI18nInstance } from "./config.js";
import { serializeI18nForHydration, hydrateI18nFromWindow } from "./serializer.js";
describe("serializeI18nForHydration", () => {
it("returns a JSON string containing locale and resources", async () => {
const i18n = await createI18nInstance({ locale: "ru" });
const json = serializeI18nForHydration(i18n);
const parsed = JSON.parse(json);
expect(parsed.locale).toBe("ru");
expect(parsed.resources).toBeDefined();
expect(parsed.resources.common).toBeDefined();
expect(parsed.resources.common["BOARD"]).toBeDefined();
});
});
describe("hydrateI18nFromWindow", () => {
afterEach(() => {
// Clean up the global
if (typeof globalThis !== "undefined") {
delete (globalThis as Record<string, unknown>).__I18N__;
}
});
it("roundtrips: serialize → inject into globalThis.__I18N__ → hydrate", async () => {
const original = await createI18nInstance({ locale: "ru" });
const json = serializeI18nForHydration(original);
const payload = JSON.parse(json);
// Simulate the SSR injection into the global scope
(globalThis as Record<string, unknown>).__I18N__ = payload;
const hydrated = await hydrateI18nFromWindow();
expect(hydrated.language).toBe("ru");
expect(hydrated.t("BOARD.DEPARTURE")).toBe(original.t("BOARD.DEPARTURE"));
});
it("throws if __I18N__ is not set", async () => {
await expect(hydrateI18nFromWindow()).rejects.toThrow(/__I18N__/);
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
```bash
pnpm test src/i18n/serializer
```
- [ ] **Step 3: Write implementation**
Create `src/i18n/serializer.ts`:
```typescript
import type i18next from "i18next";
import { createI18nInstance } from "./config.js";
import { isLanguage } from "./resolver.js";
interface I18nPayload {
locale: string;
resources: Record<string, Record<string, unknown>>;
}
/**
* Serializes the loaded locale bundle from an i18next instance into a JSON
* string suitable for injection into the SSR HTML under `window.__I18N__`.
*/
export function serializeI18nForHydration(i18n: typeof i18next): string {
const locale = i18n.language;
const resources: Record<string, Record<string, unknown>> = {};
for (const ns of i18n.options.ns as string[]) {
const bundle = i18n.getResourceBundle(locale, ns) as Record<string, unknown> | undefined;
if (bundle) {
resources[ns] = bundle;
}
}
const payload: I18nPayload = { locale, resources };
return JSON.stringify(payload);
}
/**
* Rehydrates an i18next instance from the SSR-injected `window.__I18N__` payload.
* Called once on the client before React hydration begins.
*/
export async function hydrateI18nFromWindow(): Promise<typeof i18next> {
const raw = (globalThis as Record<string, unknown>).__I18N__ as I18nPayload | undefined;
if (!raw) {
throw new Error(
"Cannot hydrate i18n: globalThis.__I18N__ is not set. " +
"Ensure the SSR payload includes the serialized i18n data.",
);
}
const locale = raw.locale;
if (!isLanguage(locale)) {
throw new Error(`Cannot hydrate i18n: invalid locale "${locale}" in __I18N__ payload.`);
}
return createI18nInstance({
locale,
initialResources: raw.resources,
});
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
```bash
pnpm test src/i18n/serializer
```
- [ ] **Step 5: Typecheck + lint**
```bash
pnpm typecheck && pnpm lint
```
- [ ] **Step 6: Commit**
```bash
git add src/i18n/serializer.ts src/i18n/serializer.test.ts
git commit -m "Add SSR↔client i18n hydration serializer"
```
---
## Task 5 — Create `src/i18n/provider.tsx`
**Files:**
- Create: `src/i18n/provider.tsx`
**Contract (from master plan §1C):** React Context provider + `<I18nProvider>` component + `useI18n()` accessor. Re-exports `useTranslation` from `react-i18next` so feature code never imports `react-i18next` directly (enforced by 1A-3's ESLint rule).
This file does NOT get TDD'd — it's a thin React wrapper with no logic, and testing it requires a render environment that 1F-layout will exercise.
- [ ] **Step 1: Write `src/i18n/provider.tsx`**
```tsx
import { I18nextProvider, useTranslation as useTranslationOriginal } from "react-i18next";
import type { ReactNode } from "react";
import type i18next from "i18next";
/**
* Wraps the i18next provider. All downstream code accesses translations
* through this provider and the re-exported hooks below.
*/
export function I18nProvider({
i18n,
children,
}: {
i18n: typeof i18next;
children: ReactNode;
}): JSX.Element {
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}
/**
* Re-export of react-i18next's useTranslation. Feature code MUST import
* from "@/i18n/provider", never from "react-i18next" directly (enforced
* by the no-restricted-imports ESLint rule in 1A-3).
*/
export const useTranslation = useTranslationOriginal;
/**
* Convenience alias for accessing the i18next instance from context.
* Same as useTranslation().i18n.
*/
export function useI18n(): typeof i18next {
const { i18n } = useTranslation();
return i18n;
}
```
- [ ] **Step 2: Typecheck + lint**
```bash
pnpm typecheck && pnpm lint
```
Expected: both exit 0. The `no-restricted-imports` rule allows `react-i18next` in `src/i18n/provider.tsx` (it's in the ignores list set up in 1A-3).
- [ ] **Step 3: Commit**
```bash
git add src/i18n/provider.tsx
git commit -m "Add I18nProvider with useTranslation re-export for feature code"
```
---
## Task 6 — Full exit-gate verification
- [ ] **Step 1: Quality gates**
```bash
pnpm typecheck && pnpm lint && pnpm test
```
Expected: all pass. Test count should be 21 (from 1A) + resolver tests + config tests + serializer tests = ~33+ total.
- [ ] **Step 2: Verify resolver→config→serializer roundtrip in one shot**
```bash
node -e "
(async () => {
const { createI18nInstance } = await import('./src/i18n/config.js');
const { serializeI18nForHydration } = await import('./src/i18n/serializer.js');
const i18n = await createI18nInstance({ locale: 'ru' });
console.log('t(BOARD.DEPARTURE):', i18n.t('BOARD.DEPARTURE'));
const json = serializeI18nForHydration(i18n);
const payload = JSON.parse(json);
console.log('Serialized locale:', payload.locale);
console.log('Serialized keys sample:', Object.keys(payload.resources.common).slice(0,5));
console.log('ROUNDTRIP OK');
})();
" 2>&1 || echo "Node roundtrip failed — check module resolution"
```
Expected: prints `t(BOARD.DEPARTURE): Вылет`, locale `ru`, some key names, and `ROUNDTRIP OK`.
- [ ] **Step 3: Verify git status clean**
```bash
git status
```
---
## Self-review
**Spec coverage.** Master plan §1C exports:
- `createI18nInstance` factory → Task 3
- `Language` type + resolver functions → Task 2
- 9 locale JSON files → Task 1
- `serializeI18nForHydration` + `hydrateI18nFromWindow` → Task 4
- `I18nProvider` + `useTranslation` re-export + `useI18n` → Task 5
**Placeholder scan.** No TBD/TODO. All code blocks are complete.
**Type consistency.**
- `Language` type defined in resolver.ts, imported by config.ts, serializer.ts. Same shape everywhere.
- `createI18nInstance` returns `Promise<typeof i18next>`. Consistent in config.ts, serializer.ts, and tests.
- `i18next-icu` plugin loaded in config.ts — matches master plan 1C dependency list.
File diff suppressed because it is too large Load Diff
@@ -1,79 +0,0 @@
# Phase 1E -- SignalR wrapper contracts
## Goal
Deliver a reference-counted SignalR connection wrapper and a generic SSR-safe React hook (`useLiveFlights`) that subscribes to live flight data channels. All SignalR imports are dynamic so they never enter the SSR bundle.
## Deliverables
| File | Purpose |
|---|---|
| `src/shared/signalr/connection.ts` | `SignalRConnection` class, `getSharedConnection`, `ConnectionStatus` type, `HubOptions` interface |
| `src/shared/signalr/connection.test.ts` | Vitest tests for connection lifecycle, ref-counting, grace period, status changes |
| `src/shared/hooks/useLiveFlights.ts` | Generic live-data hook wrapping SignalR subscription |
| `src/shared/hooks/useLiveFlights.test.ts` | Vitest tests for hook behavior including SSR path |
## Tasks
### Task 1 -- Install `@microsoft/signalr`
Add `@microsoft/signalr` to the root `package.json` dependencies.
**Commit:** "Add @microsoft/signalr dependency"
### Task 2 -- `SignalRConnection` class + tests
Create `src/shared/signalr/connection.ts` with:
- `HubOptions` interface: `hubUrl`, `reconnectDelaysMs` (default `[0, 2000, 10000, 30000]`), `gracePeriodMs` (default `5000`)
- `ConnectionStatus` type: `"idle" | "connecting" | "live" | "reconnecting" | "offline"`
- `SignalRConnection` class:
- Constructor takes `HubOptions`, uses dynamic `import("@microsoft/signalr")` to build connection
- `subscribe(channel, handler)` returns unsubscribe fn; increments ref count; starts connection on first subscriber
- `onStatusChange(handler)` returns unsubscribe fn
- `get status()` returns current `ConnectionStatus`
- Reference counting: connection closes after last unsubscribe + grace period elapses
- `getSharedConnection(options)` -- singleton map keyed by `hubUrl`
Create `src/shared/signalr/connection.test.ts` with tests:
1. Two rapid subscribes produce exactly one `HubConnection.start()` call
2. Unmount + remount within grace period reuses connection (no second `start()`)
3. Unmount + remount after grace period creates fresh connection
4. Status transitions fire `onStatusChange` handlers
5. `getSharedConnection` returns same instance for same `hubUrl`
**Commit:** "Add SignalRConnection ref-counted wrapper with tests"
### Task 3 -- `useLiveFlights` hook + tests
Create `src/shared/hooks/useLiveFlights.ts`:
- Generic `useLiveFlights<TParams, TData>(params, initialData, config)` hook
- SSR-safe: checks `typeof window !== "undefined"` before subscribing
- Uses `useEffect` for subscribe/unsubscribe lifecycle
- Returns `{ data: TData[]; connectionStatus: ConnectionStatus }`
Create `src/shared/hooks/useLiveFlights.test.ts`:
1. SSR path returns `initialData` without importing `@microsoft/signalr`
2. Client path subscribes to the correct channel
3. Data updates when messages arrive
4. Cleanup unsubscribes on unmount
**Commit:** "Add useLiveFlights SSR-safe hook with tests"
### Task 4 -- Verification
- `pnpm typecheck && pnpm lint && pnpm test`
- Confirm ESLint SSR-bundle guard still blocks `@microsoft/signalr` in `src/routes/`
**Commit:** none (verification only)
## Exit gate
- Two rapid `useEffect` mounts (Strict Mode simulation) result in exactly one `HubConnection.start()` call
- Unmount + remount within grace period reuses connection
- Unmount + remount after grace period creates fresh connection
- SSR render path does not import `@microsoft/signalr`
- `pnpm typecheck && pnpm lint && pnpm test` all green
@@ -1,262 +0,0 @@
# Phase 1F-layout — Root layout + routes + error mapper contracts
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the root layout provider stack, locale-scoped layout, error boundary, error-to-HTTP mapper, error pages, and smoke route — so that all downstream feature routes render inside a fully-wired provider tree (`LoggerProvider` > `ApiClientProvider` > `I18nProvider` > `ErrorBoundary`) and error handling works end-to-end.
**Architecture:** Modern.js file-based routing. `src/routes/layout.tsx` wraps children with the global providers (Logger, ApiClient, ErrorBoundary). `src/routes/[lang]/layout.tsx` validates the `lang` param, creates a request-scoped i18n instance, and wraps children with `<I18nProvider>`. Error pages live at `src/routes/error/[code]/page.tsx`. The smoke route at `src/routes/[lang]/smoke/page.tsx` exercises logger, i18n, and locale display.
**Tech Stack:** React 18, Modern.js SSR, i18next, Vitest.
**Prerequisites:** 1A-1 (skeleton), 1A-2 (MF builds), 1C (i18n), 1D (API client), 1G-logger (logger).
---
## File structure
| File | Responsibility | Task |
|---|---|---|
| `src/ui/errors/ErrorBoundary.tsx` | React error boundary with retry | 1 |
| `src/routes/error/map.ts` | `errorToResponse()` mapper | 2 |
| `src/routes/error/map.test.ts` | TDD tests for mapper | 2 |
| `src/routes/layout.tsx` | Root layout with provider stack | 3 |
| `src/routes/[lang]/layout.tsx` | Locale-scoped layout | 3 |
| `src/routes/error/[code]/page.tsx` | Error page (404, 500, 503) | 4 |
| `src/i18n/locales/en/common.json` | Add SMOKE keys | 5 |
| `src/i18n/locales/ru/common.json` | Add SMOKE keys | 5 |
| `src/routes/[lang]/smoke/page.tsx` | Smoke route | 5 |
---
## Task 1 — ErrorBoundary component
**Files:**
- Create: `src/ui/errors/ErrorBoundary.tsx`
- [ ] **Step 1: Create the ErrorBoundary class component**
The ErrorBoundary must be a class component (React requirement for `componentDidCatch`). It catches errors in its subtree, renders a fallback UI with a "Retry" button that resets the boundary state.
```tsx
// src/ui/errors/ErrorBoundary.tsx
import { Component } from "react";
import type { ReactNode, ErrorInfo } from "react";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("[ErrorBoundary]", error, info.componentStack);
}
handleRetry = (): void => {
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div role="alert">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button type="button" onClick={this.handleRetry}>Retry</button>
</div>
);
}
return this.props.children;
}
}
```
- [ ] **Step 2: Verify typecheck**
```bash
pnpm typecheck
```
- [ ] **Step 3: Commit**
```bash
git add src/ui/errors/ErrorBoundary.tsx
git commit -m "Add ErrorBoundary class component with retry support"
```
---
## Task 2 — TDD errorToResponse mapper
**Files:**
- Create: `src/routes/error/map.ts`
- Create: `src/routes/error/map.test.ts`
- [ ] **Step 1: Write failing tests**
Tests cover all four mapping rules:
1. `ApiHttpError(404)` -> `{ status: 404, errorCode: "not_found" }`
2. `ApiHttpError(502)` -> `{ status: 500, errorCode: "internal" }`
3. `ApiTimeoutError` -> `{ status: 503, headers: { "Retry-After": "30" }, errorCode: "unavailable" }`
4. Unknown error -> `{ status: 500, errorCode: "internal" }`
- [ ] **Step 2: Write the implementation**
```ts
export interface ErrorResponse {
status: 404 | 500 | 503;
headers?: Record<string, string>;
errorCode: "not_found" | "internal" | "unavailable";
}
export function errorToResponse(error: unknown): ErrorResponse;
```
- [ ] **Step 3: Run tests — all must pass**
```bash
pnpm test -- src/routes/error/map.test.ts
```
- [ ] **Step 4: Commit**
```bash
git add src/routes/error/map.ts src/routes/error/map.test.ts
git commit -m "Add errorToResponse mapper with TDD tests"
```
---
## Task 3 — Root layout + locale-scoped layout
**Files:**
- Modify: `src/routes/layout.tsx` (replace 1A-2 stub)
- Create: `src/routes/[lang]/layout.tsx`
- [ ] **Step 1: Update root layout**
Replace the stub with the real provider stack:
- `<LoggerProvider>` wrapping everything (logger from `createRootLogger()`)
- `<ApiClientProvider>` with a default-locale ApiClient
- `<ErrorBoundary>` wrapping children
- [ ] **Step 2: Create locale-scoped layout**
`src/routes/[lang]/layout.tsx`:
- Validate `params.lang` using `isLanguage()` from `@/i18n/resolver`
- If invalid lang, redirect to `/ru/` (or render 404)
- Create i18n instance via `createI18nInstance({ locale: params.lang })`
- Wrap children with `<I18nProvider>`
- [ ] **Step 3: Verify typecheck**
```bash
pnpm typecheck
```
- [ ] **Step 4: Commit**
```bash
git add src/routes/layout.tsx src/routes/\[lang\]/layout.tsx
git commit -m "Wire root layout provider stack and locale-scoped layout"
```
---
## Task 4 — Error pages
**Files:**
- Create: `src/routes/error/[code]/page.tsx`
- [ ] **Step 1: Create error page component**
Simple text-based UI for codes 404, 500, 503. Renders heading, description, and a link back to home. No design system dependency.
- [ ] **Step 2: Verify typecheck**
```bash
pnpm typecheck
```
- [ ] **Step 3: Commit**
```bash
git add src/routes/error/\[code\]/page.tsx
git commit -m "Add error pages for 404, 500, 503 codes"
```
---
## Task 5 — Smoke route + i18n keys
**Files:**
- Modify: `src/i18n/locales/en/common.json` (add SMOKE keys)
- Modify: `src/i18n/locales/ru/common.json` (add SMOKE keys)
- Create: `src/routes/[lang]/smoke/page.tsx`
- [ ] **Step 1: Add SMOKE i18n keys**
Add to `en/common.json`:
```json
"SMOKE": {
"HEADING": "Smoke test page"
}
```
Add to `ru/common.json`:
```json
"SMOKE": {
"HEADING": "Страница проверки"
}
```
- [ ] **Step 2: Create smoke page**
`src/routes/[lang]/smoke/page.tsx`:
- Uses `useTranslation()` to render `t("SMOKE.HEADING")`
- Uses `useLogger()` to emit an info log on mount (via `useEffect`)
- Displays the current locale from the URL params
- [ ] **Step 3: Verify typecheck + lint + test**
```bash
pnpm typecheck && pnpm lint && pnpm test
```
- [ ] **Step 4: Build**
```bash
pnpm build:standalone
```
- [ ] **Step 5: Commit**
```bash
git add src/i18n/locales/en/common.json src/i18n/locales/ru/common.json src/routes/\[lang\]/smoke/page.tsx
git commit -m "Add smoke route exercising logger, i18n, and locale display"
```
---
## Exit gate
- [ ] `pnpm typecheck && pnpm lint && pnpm test` — all pass
- [ ] `pnpm build:standalone` — succeeds
- [ ] `src/routes/[lang]/smoke/page.tsx` exists
- [ ] `src/ui/errors/ErrorBoundary.tsx` exists
- [ ] `src/routes/error/map.ts` exists with `errorToResponse()` exported
- [ ] `src/routes/error/[code]/page.tsx` exists
- [ ] `src/routes/layout.tsx` wraps children with LoggerProvider, ApiClientProvider, ErrorBoundary
- [ ] `src/routes/[lang]/layout.tsx` validates lang and provides I18nProvider
@@ -1,493 +0,0 @@
# Phase 1F-seo — SeoHead + hreflang + JsonLdRenderer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the SEO infrastructure — `buildHreflangSet` for 9 languages + x-default, `JsonLdRenderer` with `schema-dts` typing, and `SeoHead` component emitting the full `<head>` shape (title, meta, canonical, hreflang, OG, Twitter, JSON-LD) — so that 1F-layout and all downstream features can render SEO-complete pages with `<SeoHead title={...} hreflang={buildHreflangSet(...)} jsonLd={data} />`.
**Architecture:** Pure functions and thin React components with no runtime dependencies on 1C/1D/1G. `hreflang.ts` builds the 9-language + x-default link set. `json-ld.tsx` serializes `schema-dts` `Thing` objects into safe `<script type="application/ld+json">` blocks. `SeoHead.tsx` composes both into a single `<head>` fragment. The `Language` type is imported from `@/i18n/resolver` (seeded in 1C).
**Tech Stack:** `schema-dts` (Google's Schema.org TypeScript definitions).
**Prerequisites:** 1A-1 (skeleton), 1A-3 (ESLint boundaries), 1C (Language type from `@/i18n/resolver`).
---
## File structure
| File | Responsibility | Task |
|---|---|---|
| `src/shared/seo/hreflang.ts` | `buildHreflangSet` function | 2 |
| `src/shared/seo/hreflang.test.ts` | Tests | 2 |
| `src/shared/seo/json-ld.tsx` | `JsonLdRenderer` + `serializeJsonLd` | 3 |
| `src/shared/seo/json-ld.test.ts` | Tests | 3 |
| `src/ui/seo/SeoHead.tsx` | `<SeoHead>` component | 4 |
---
## Task 1 — Install `schema-dts` dependency
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Install**
```bash
pnpm add schema-dts
```
- [ ] **Step 2: Verify**
```bash
pnpm typecheck
```
- [ ] **Step 3: Commit**
```bash
git add package.json pnpm-lock.yaml
git commit -m "Add schema-dts dependency for typed JSON-LD generation"
```
---
## Task 2 — TDD `hreflang.ts`
**Files:**
- Create: `src/shared/seo/hreflang.ts`
- Create: `src/shared/seo/hreflang.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/shared/seo/hreflang.test.ts`:
```typescript
import { describe, expect, it } from "vitest";
import { buildHreflangSet } from "./hreflang.js";
describe("buildHreflangSet", () => {
const LANGUAGES = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"] as const;
it("returns entries for all 9 languages plus x-default", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "/onlineboard/flight/SU100-2025-01-15",
});
expect(result).toHaveLength(10); // 9 languages + x-default
});
it("includes all 9 languages", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "/smoke",
});
const langs = result.map((entry) => entry.lang);
for (const lang of LANGUAGES) {
expect(langs).toContain(lang);
}
});
it("x-default points to the ru variant", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "/smoke",
});
const xDefault = result.find((entry) => entry.lang === "x-default");
expect(xDefault).toBeDefined();
expect(xDefault?.href).toBe("https://www.aeroflot.ru/ru/smoke");
});
it("builds correct href for each language", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "/onlineboard",
});
const en = result.find((entry) => entry.lang === "en");
expect(en?.href).toBe("https://www.aeroflot.ru/en/onlineboard");
const ja = result.find((entry) => entry.lang === "ja");
expect(ja?.href).toBe("https://www.aeroflot.ru/ja/onlineboard");
});
it("preserves paths with nested segments", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "/onlineboard/flight/SU100-2025-01-15",
});
const fr = result.find((entry) => entry.lang === "fr");
expect(fr?.href).toBe("https://www.aeroflot.ru/fr/onlineboard/flight/SU100-2025-01-15");
});
it("handles root path", () => {
const result = buildHreflangSet({
canonicalOrigin: "https://www.aeroflot.ru",
pathWithoutLocale: "",
});
const ru = result.find((entry) => entry.lang === "ru");
expect(ru?.href).toBe("https://www.aeroflot.ru/ru");
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
```bash
pnpm test src/shared/seo/hreflang
```
- [ ] **Step 3: Write implementation**
Create `src/shared/seo/hreflang.ts`:
```typescript
import type { Language } from "@/i18n/resolver";
const LANGUAGES: readonly Language[] = ["ru", "en", "es", "fr", "it", "ja", "ko", "zh", "de"];
const X_DEFAULT_LANGUAGE: Language = "ru";
export interface HreflangEntry {
lang: Language | "x-default";
href: string;
}
/**
* Builds the full set of reciprocal hreflang links for a given path.
* Returns 9 language entries + 1 x-default entry (pointing to ru).
*/
export function buildHreflangSet(args: {
canonicalOrigin: string;
pathWithoutLocale: string;
}): HreflangEntry[] {
const { canonicalOrigin, pathWithoutLocale } = args;
const entries: HreflangEntry[] = LANGUAGES.map((lang) => ({
lang,
href: `${canonicalOrigin}/${lang}${pathWithoutLocale}`,
}));
entries.push({
lang: "x-default",
href: `${canonicalOrigin}/${X_DEFAULT_LANGUAGE}${pathWithoutLocale}`,
});
return entries;
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
```bash
pnpm test src/shared/seo/hreflang
```
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/shared/seo/hreflang.ts src/shared/seo/hreflang.test.ts
git commit -m "Add buildHreflangSet for 9 languages + x-default"
```
---
## Task 3 — TDD `json-ld.tsx`
**Files:**
- Create: `src/shared/seo/json-ld.tsx`
- Create: `src/shared/seo/json-ld.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/shared/seo/json-ld.test.ts`:
```typescript
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement } from "react";
import type { Thing } from "schema-dts";
import { JsonLdRenderer, serializeJsonLd } from "./json-ld.js";
describe("serializeJsonLd", () => {
it("serializes a single Thing to a JSON-LD string", () => {
const data: Thing = {
"@type": "WebSite",
name: "Aeroflot",
url: "https://www.aeroflot.ru",
};
const result = serializeJsonLd(data);
const parsed = JSON.parse(result);
expect(parsed["@context"]).toBe("https://schema.org");
expect(parsed["@type"]).toBe("WebSite");
expect(parsed.name).toBe("Aeroflot");
});
it("serializes an array of Things with @context on each", () => {
const data: Thing[] = [
{ "@type": "WebSite", name: "Aeroflot" } as Thing,
{ "@type": "Organization", name: "Aeroflot PJSC" } as Thing,
];
const result = serializeJsonLd(data);
const parsed = JSON.parse(result);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed).toHaveLength(2);
expect(parsed[0]["@context"]).toBe("https://schema.org");
expect(parsed[1]["@context"]).toBe("https://schema.org");
});
it("escapes </script> to prevent injection", () => {
const data: Thing = {
"@type": "WebSite",
name: '</script><script>alert("xss")</script>',
};
const result = serializeJsonLd(data);
expect(result).not.toContain("</script>");
});
});
describe("JsonLdRenderer", () => {
it("renders a <script type=application/ld+json> tag", () => {
const data: Thing = {
"@type": "WebSite",
name: "Aeroflot",
url: "https://www.aeroflot.ru",
};
const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data }));
expect(html).toContain('<script type="application/ld+json">');
expect(html).toContain("</script>");
expect(html).toContain('"@context":"https://schema.org"');
expect(html).toContain('"@type":"WebSite"');
});
it("round-trips: serialize → DOM string contains valid JSON-LD", () => {
const data: Thing = {
"@type": "Organization",
name: "Aeroflot PJSC",
url: "https://www.aeroflot.ru",
};
const html = renderToStaticMarkup(createElement(JsonLdRenderer, { data }));
// Extract JSON from the script tag
const match = html.match(/<script[^>]*>([\s\S]*?)<\/script>/);
expect(match).not.toBeNull();
const json = match![1]!.replace(/\\u003c/g, "<");
const parsed = JSON.parse(json);
expect(parsed["@context"]).toBe("https://schema.org");
expect(parsed["@type"]).toBe("Organization");
expect(parsed.name).toBe("Aeroflot PJSC");
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
```bash
pnpm test src/shared/seo/json-ld
```
- [ ] **Step 3: Write implementation**
Create `src/shared/seo/json-ld.tsx`:
```tsx
import type { Thing } from "schema-dts";
export interface JsonLdRendererProps {
data: Thing | Thing[];
}
/**
* Serializes a schema-dts Thing (or array of Things) to a JSON-LD string.
* Adds "@context": "https://schema.org" to each item.
* Escapes </script> sequences to prevent XSS.
*/
export function serializeJsonLd(data: Thing | Thing[]): string {
const withContext = Array.isArray(data)
? data.map((item) => ({ "@context": "https://schema.org" as const, ...item }))
: { "@context": "https://schema.org" as const, ...data };
return JSON.stringify(withContext).replace(/<\//g, "\\u003c/");
}
/**
* Renders a <script type="application/ld+json"> block with the serialized
* JSON-LD data. Safe for SSR — the content is escaped against script injection.
*/
export function JsonLdRenderer({ data }: JsonLdRendererProps): JSX.Element {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
);
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
```bash
pnpm test src/shared/seo/json-ld
```
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/shared/seo/json-ld.tsx src/shared/seo/json-ld.test.ts
git commit -m "Add JsonLdRenderer and serializeJsonLd with schema-dts typing"
```
---
## Task 4 — Create `SeoHead.tsx` (no TDD)
**Files:**
- Create: `src/ui/seo/SeoHead.tsx`
No TDD — thin React component assembling head tags. Tested by 1F-layout integration.
- [ ] **Step 1: Write implementation**
Create `src/ui/seo/SeoHead.tsx`:
```tsx
import type { Language } from "@/i18n/resolver";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import type { Thing } from "schema-dts";
export interface SeoHeadProps {
title: string;
description: string;
canonical: string;
hreflang: Array<{ lang: Language | "x-default"; href: string }>;
og: {
title: string;
description: string;
url: string;
image: string;
type: "website" | "article";
locale: string;
siteName: string;
};
twitter?: {
card: "summary" | "summary_large_image";
title?: string;
description?: string;
image?: string;
};
jsonLd?: Thing | Thing[];
noindex?: boolean;
}
/**
* Renders the full SEO <head> fragment: title, meta description, canonical,
* hreflang alternates, Open Graph tags, Twitter Card tags, and JSON-LD.
*/
export function SeoHead({
title,
description,
canonical,
hreflang,
og,
twitter,
jsonLd,
noindex,
}: SeoHeadProps): JSX.Element {
return (
<>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex,nofollow" />}
{/* Hreflang alternates */}
{hreflang.map((entry) => (
<link
key={entry.lang}
rel="alternate"
hrefLang={entry.lang}
href={entry.href}
/>
))}
{/* Open Graph */}
<meta property="og:title" content={og.title} />
<meta property="og:description" content={og.description} />
<meta property="og:url" content={og.url} />
<meta property="og:image" content={og.image} />
<meta property="og:type" content={og.type} />
<meta property="og:locale" content={og.locale} />
<meta property="og:site_name" content={og.siteName} />
{/* Twitter Card */}
{twitter && (
<>
<meta name="twitter:card" content={twitter.card} />
{twitter.title && <meta name="twitter:title" content={twitter.title} />}
{twitter.description && <meta name="twitter:description" content={twitter.description} />}
{twitter.image && <meta name="twitter:image" content={twitter.image} />}
</>
)}
{/* JSON-LD */}
{jsonLd && <JsonLdRenderer data={jsonLd} />}
</>
);
}
```
- [ ] **Step 2: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/ui/seo/SeoHead.tsx
git commit -m "Add SeoHead component with canonical, hreflang, OG, Twitter, and JSON-LD"
```
---
## Task 5 — Exit-gate verification
- [ ] **Step 1: All gates**
```bash
pnpm typecheck && pnpm lint && pnpm test
```
Expected: all pass. hreflang tests cover 9 languages + x-default. JsonLdRenderer round-trips through serialize to DOM string.
- [ ] **Step 2: Git status clean**
```bash
git status
```
---
## Self-review
**Spec coverage.** Master plan §1F-seo:
- `buildHreflangSet` with 9 languages + `x-default` (x-default → ru) → Task 2
- `JsonLdRenderer` + `serializeJsonLd` with `schema-dts` `Thing` type → Task 3
- `SeoHead` with title, meta description, canonical, hreflang links, OG tags, Twitter Card, JSON-LD, noindex → Task 4
- XSS protection via `</script>` escaping in `serializeJsonLd` → Task 3
**Exit gate alignment:**
- "buildHreflangSet covers 9 langs + x-default" — Task 2 tests
- "SeoHead emits the full <head> shape" — Task 4 component (tested by 1F-layout integration)
- "JsonLdRenderer round-trips a typed Thing through serializeJsonLd → DOM string" — Task 3 tests
**Type consistency.** `Language` from `@/i18n/resolver` (seeded in 1C). `Thing` from `schema-dts`. `SeoHeadProps` matches the master plan contract exactly.
@@ -1,600 +0,0 @@
# Phase 1G-analytics — Analytics Facade + Stub Adapters Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the analytics facade — a test-observable event sink, four stub adapters (Yandex.Metrica, CTM, Variocube, Dynatrace), a `createAnalytics()` factory that fans out `track`/`page` calls to enabled adapters with consent gating, plus the `<AnalyticsLoader>` component and `useAnalytics()` hook — so that 1F-layout and all downstream features can emit analytics events with `analytics.track("search.submit", { query })`.
**Architecture:** `types.ts` is already seeded (1A-1) with `AnalyticsProviders`, `AnalyticsProps`, `AnalyticsEvent`, `Analytics`, and `AnalyticsAdapter`. Each stub adapter emits `AnalyticsEvent` records to a shared sink (`sink.ts`) for test observability. `facade.ts` accepts enabled providers + consent flags and fans out to adapters. In production, the sink is a no-op ring buffer; in test, `getRecordedEvents()` / `resetEvents()` allow assertions. Real vendor scripts replace the stubs in Phase 2A after A7 resolves.
**Tech Stack:** No new dependencies. Stubs use no vendor SDKs.
**Prerequisites:** 1A-1 (skeleton + types.ts seeded), 1A-3 (ESLint boundaries), 1G-logger (Logger types for facade logging).
---
## File structure
| File | Responsibility | Task |
|---|---|---|
| `src/observability/analytics/sink.ts` | Test-observable event sink | 1 |
| `src/observability/analytics/sink.test.ts` | Tests | 1 |
| `src/observability/analytics/adapters/metrica.ts` | Yandex.Metrica stub adapter | 2 |
| `src/observability/analytics/adapters/ctm.ts` | CTM stub adapter | 2 |
| `src/observability/analytics/adapters/variocube.ts` | Variocube stub adapter | 2 |
| `src/observability/analytics/adapters/dynatrace.ts` | Dynatrace stub adapter | 2 |
| `src/observability/analytics/facade.ts` | `createAnalytics()` factory | 3 |
| `src/observability/analytics/facade.test.ts` | Tests | 3 |
| `src/observability/analytics/loader.tsx` | `<AnalyticsLoader>` component | 4 |
| `src/observability/analytics/provider.tsx` | `useAnalytics()` hook | 5 |
---
## Task 1 — TDD `sink.ts`
**Files:**
- Create: `src/observability/analytics/sink.ts`
- Create: `src/observability/analytics/sink.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/observability/analytics/sink.test.ts`:
```typescript
import { describe, expect, it, beforeEach } from "vitest";
import { emitEvent, getRecordedEvents, resetEvents } from "./sink.js";
import type { AnalyticsEvent } from "./types.js";
describe("analytics sink", () => {
beforeEach(() => {
resetEvents();
});
it("records emitted events", () => {
const event: AnalyticsEvent = {
kind: "track",
name: "test.click",
props: { button: "cta" },
provider: "metrica",
ts: new Date().toISOString(),
};
emitEvent(event);
expect(getRecordedEvents()).toHaveLength(1);
expect(getRecordedEvents()[0]).toEqual(event);
});
it("records multiple events in order", () => {
emitEvent({ kind: "track", name: "a", props: {}, provider: "ctm", ts: "t1" });
emitEvent({ kind: "page", name: "/home", props: {}, provider: "dynatrace", ts: "t2" });
const events = getRecordedEvents();
expect(events).toHaveLength(2);
expect(events[0]?.name).toBe("a");
expect(events[1]?.name).toBe("/home");
});
it("resetEvents clears all recorded events", () => {
emitEvent({ kind: "track", name: "x", props: {}, provider: "variocube", ts: "t" });
expect(getRecordedEvents()).toHaveLength(1);
resetEvents();
expect(getRecordedEvents()).toHaveLength(0);
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
```bash
pnpm test src/observability/analytics/sink
```
- [ ] **Step 3: Write implementation**
Create `src/observability/analytics/sink.ts`:
```typescript
import type { AnalyticsEvent } from "./types.js";
let events: AnalyticsEvent[] = [];
/**
* Emit an analytics event to the test-observable sink.
* In production, this is a no-op ring buffer (capped to prevent memory leaks).
* In test, events are retained for assertion via getRecordedEvents().
*/
export function emitEvent(event: AnalyticsEvent): void {
events.push(event);
// Ring buffer: cap at 1000 events to prevent unbounded growth
if (events.length > 1000) {
events = events.slice(-500);
}
}
/** Returns all recorded events (for test assertions). */
export function getRecordedEvents(): readonly AnalyticsEvent[] {
return events;
}
/** Clears all recorded events (for test teardown). */
export function resetEvents(): void {
events = [];
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
```bash
pnpm test src/observability/analytics/sink
```
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/analytics/sink.ts src/observability/analytics/sink.test.ts
git commit -m "Add test-observable analytics event sink"
```
---
## Task 2 — Create 4 stub adapters (no TDD)
**Files:**
- Create: `src/observability/analytics/adapters/metrica.ts`
- Create: `src/observability/analytics/adapters/ctm.ts`
- Create: `src/observability/analytics/adapters/variocube.ts`
- Create: `src/observability/analytics/adapters/dynatrace.ts`
- [ ] **Step 1: Write all four adapters**
Each adapter follows the same pattern. Create `src/observability/analytics/adapters/metrica.ts`:
```typescript
import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
import { emitEvent } from "../sink.js";
export class MetricaAdapter implements AnalyticsAdapter {
readonly name = "metrica";
async load(): Promise<void> {
// Stub: real Yandex.Metrica script loads in Phase 2A (after A7 resolves)
}
track(event: string, props: AnalyticsProps = {}): void {
emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
}
page(url: string, props: AnalyticsProps = {}): void {
emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
}
}
```
Create `src/observability/analytics/adapters/ctm.ts`:
```typescript
import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
import { emitEvent } from "../sink.js";
export class CtmAdapter implements AnalyticsAdapter {
readonly name = "ctm";
async load(): Promise<void> {
// Stub: real CTM script loads in Phase 2A
}
track(event: string, props: AnalyticsProps = {}): void {
emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
}
page(url: string, props: AnalyticsProps = {}): void {
emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
}
}
```
Create `src/observability/analytics/adapters/variocube.ts`:
```typescript
import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
import { emitEvent } from "../sink.js";
export class VariocubeAdapter implements AnalyticsAdapter {
readonly name = "variocube";
async load(): Promise<void> {
// Stub: real Variocube script loads in Phase 2A
}
track(event: string, props: AnalyticsProps = {}): void {
emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
}
page(url: string, props: AnalyticsProps = {}): void {
emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
}
}
```
Create `src/observability/analytics/adapters/dynatrace.ts`:
```typescript
import type { AnalyticsAdapter, AnalyticsProps } from "../types.js";
import { emitEvent } from "../sink.js";
export class DynatraceAdapter implements AnalyticsAdapter {
readonly name = "dynatrace";
async load(): Promise<void> {
// Stub: real Dynatrace (Key-Astrom) script loads in Phase 2A
}
track(event: string, props: AnalyticsProps = {}): void {
emitEvent({ kind: "track", name: event, props, provider: this.name, ts: new Date().toISOString() });
}
page(url: string, props: AnalyticsProps = {}): void {
emitEvent({ kind: "page", name: url, props, provider: this.name, ts: new Date().toISOString() });
}
}
```
- [ ] **Step 2: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/analytics/adapters/
git commit -m "Add four stub analytics adapters (metrica, ctm, variocube, dynatrace)"
```
---
## Task 3 — TDD `facade.ts`
**Files:**
- Create: `src/observability/analytics/facade.ts`
- Create: `src/observability/analytics/facade.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/observability/analytics/facade.test.ts`:
```typescript
import { describe, expect, it, beforeEach, vi } from "vitest";
import { createAnalytics } from "./facade.js";
import { getRecordedEvents, resetEvents } from "./sink.js";
import type { Logger } from "@/observability/logger/types";
function mockLogger(): Logger {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn(() => mockLogger()),
};
}
describe("createAnalytics", () => {
beforeEach(() => {
resetEvents();
});
it("fans out track() to all 4 enabled adapters", () => {
const analytics = createAnalytics({
enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true },
consent: { analytics: true, telemetry: true },
logger: mockLogger(),
});
analytics.track("test.event", { key: "value" });
const events = getRecordedEvents();
expect(events).toHaveLength(4);
const providers = events.map((e) => e.provider).sort();
expect(providers).toEqual(["ctm", "dynatrace", "metrica", "variocube"]);
for (const event of events) {
expect(event.kind).toBe("track");
expect(event.name).toBe("test.event");
expect(event.props).toEqual({ key: "value" });
}
});
it("fans out page() to all 4 enabled adapters", () => {
const analytics = createAnalytics({
enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true },
consent: { analytics: true, telemetry: true },
logger: mockLogger(),
});
analytics.page("/ru/online-board");
const events = getRecordedEvents();
expect(events).toHaveLength(4);
for (const event of events) {
expect(event.kind).toBe("page");
expect(event.name).toBe("/ru/online-board");
}
});
it("consent.analytics = false short-circuits before any adapter is invoked", () => {
const analytics = createAnalytics({
enabled: { metrica: true, ctm: true, variocube: true, dynatrace: true },
consent: { analytics: false, telemetry: true },
logger: mockLogger(),
});
analytics.track("should.not.emit");
analytics.page("/should/not/emit");
expect(getRecordedEvents()).toHaveLength(0);
});
it("disabled adapter is not invoked", () => {
const analytics = createAnalytics({
enabled: { metrica: true, ctm: false, variocube: false, dynatrace: true },
consent: { analytics: true, telemetry: true },
logger: mockLogger(),
});
analytics.track("partial.event");
const events = getRecordedEvents();
expect(events).toHaveLength(2);
const providers = events.map((e) => e.provider).sort();
expect(providers).toEqual(["dynatrace", "metrica"]);
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
```bash
pnpm test src/observability/analytics/facade
```
- [ ] **Step 3: Write implementation**
Create `src/observability/analytics/facade.ts`:
```typescript
import type { Analytics, AnalyticsAdapter, AnalyticsProps, AnalyticsProviders } from "./types.js";
import type { Logger } from "@/observability/logger/types";
import { MetricaAdapter } from "./adapters/metrica.js";
import { CtmAdapter } from "./adapters/ctm.js";
import { VariocubeAdapter } from "./adapters/variocube.js";
import { DynatraceAdapter } from "./adapters/dynatrace.js";
export interface CreateAnalyticsOptions {
enabled: AnalyticsProviders;
consent: { analytics: boolean; telemetry: boolean };
logger: Logger;
}
const NOOP_ANALYTICS: Analytics = {
track() {},
page() {},
};
/**
* Creates an Analytics instance that fans out track/page calls to enabled adapters.
* If consent.analytics is false, returns a no-op (short-circuit before any adapter).
*/
export function createAnalytics(options: CreateAnalyticsOptions): Analytics {
const { enabled, consent, logger } = options;
if (!consent.analytics) {
logger.debug("analytics consent denied, returning no-op");
return NOOP_ANALYTICS;
}
const adapters: AnalyticsAdapter[] = [];
if (enabled.metrica) adapters.push(new MetricaAdapter());
if (enabled.ctm) adapters.push(new CtmAdapter());
if (enabled.variocube) adapters.push(new VariocubeAdapter());
if (enabled.dynatrace) adapters.push(new DynatraceAdapter());
if (adapters.length === 0) {
logger.debug("no analytics adapters enabled, returning no-op");
return NOOP_ANALYTICS;
}
return {
track(event: string, props: AnalyticsProps = {}): void {
for (const adapter of adapters) {
try {
adapter.track(event, props);
} catch (err) {
logger.error("analytics adapter track failed", { provider: adapter.name, err: err as Error });
}
}
},
page(url: string, props: AnalyticsProps = {}): void {
for (const adapter of adapters) {
try {
adapter.page(url, props);
} catch (err) {
logger.error("analytics adapter page failed", { provider: adapter.name, err: err as Error });
}
}
},
};
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
```bash
pnpm test src/observability/analytics/facade
```
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/analytics/facade.ts src/observability/analytics/facade.test.ts
git commit -m "Add analytics facade with adapter fan-out and consent gating"
```
---
## Task 4 — Create `loader.tsx` (no TDD)
**Files:**
- Create: `src/observability/analytics/loader.tsx`
- [ ] **Step 1: Write implementation**
Create `src/observability/analytics/loader.tsx`:
```tsx
"use client";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import type { Analytics, AnalyticsProviders } from "./types.js";
import type { Logger } from "@/observability/logger/types";
import { createAnalytics } from "./facade.js";
import { AnalyticsContext } from "./provider.js";
export interface AnalyticsLoaderProps {
enabled: AnalyticsProviders;
consent: { analytics: boolean; telemetry: boolean };
logger: Logger;
children: ReactNode;
}
/**
* Mounts in the root layout. Waits for idle callback, then initializes
* analytics adapters and provides the Analytics instance to the tree.
*/
export function AnalyticsLoader({
enabled,
consent,
logger,
children,
}: AnalyticsLoaderProps): JSX.Element {
const [analytics, setAnalytics] = useState<Analytics | null>(null);
const initRef = useRef(false);
useEffect(() => {
if (initRef.current) return;
initRef.current = true;
const init = () => {
const instance = createAnalytics({ enabled, consent, logger });
setAnalytics(instance);
};
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
(window as any).requestIdleCallback(init);
} else {
// Fallback for environments without requestIdleCallback
setTimeout(init, 1);
}
}, [enabled, consent, logger]);
return (
<AnalyticsContext.Provider value={analytics}>
{children}
</AnalyticsContext.Provider>
);
}
```
- [ ] **Step 2: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/analytics/loader.tsx
git commit -m "Add AnalyticsLoader component with idle-callback initialization"
```
---
## Task 5 — Create `provider.tsx` (no TDD)
**Files:**
- Create: `src/observability/analytics/provider.tsx`
- [ ] **Step 1: Write implementation**
Create `src/observability/analytics/provider.tsx`:
```tsx
import { createContext, useContext } from "react";
import type { Analytics } from "./types.js";
const NOOP_ANALYTICS: Analytics = {
track() {},
page() {},
};
/**
* React context for the Analytics instance.
* Exported for use by AnalyticsLoader (which sets the provider value).
*/
export const AnalyticsContext = createContext<Analytics | null>(null);
/**
* Returns the Analytics instance from context.
* Server-side and before AnalyticsLoader initializes: returns NoopAnalytics.
* Client-side after init: returns the real facade instance.
*/
export function useAnalytics(): Analytics {
const analytics = useContext(AnalyticsContext);
return analytics ?? NOOP_ANALYTICS;
}
```
- [ ] **Step 2: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/analytics/provider.tsx
git commit -m "Add useAnalytics hook with server-safe NoopAnalytics fallback"
```
---
## Task 6 — Exit-gate verification
- [ ] **Step 1: All gates**
```bash
pnpm typecheck && pnpm lint && pnpm test
```
Expected: all pass. Sink + facade tests verify adapter fan-out, consent short-circuit, and disabled-adapter exclusion.
- [ ] **Step 2: Git status clean**
```bash
git status
```
---
## Self-review
**Spec coverage.** Master plan §1G-analytics:
- `types.ts` (already seeded in 1A-1) — `AnalyticsProviders`, `AnalyticsProps`, `AnalyticsEvent`, `Analytics`, `AnalyticsAdapter`
- `sink.ts` with `emitEvent`, `getRecordedEvents`, `resetEvents` → Task 1
- Four stub adapters (metrica, ctm, variocube, dynatrace) → Task 2
- `createAnalytics()` with consent gating and adapter fan-out → Task 3
- `<AnalyticsLoader>` with `requestIdleCallback` → Task 4
- `useAnalytics()` with NoopAnalytics server fallback → Task 5
- Consent short-circuit verified in facade tests → Task 3
**Exit gate alignment:**
- "all four stub adapters emit exactly one AnalyticsEvent to the sink" — facade test, Task 3
- "consent.analytics = false short-circuits" — facade test, Task 3
- "adapter load failure emits flights.analytics.load_failed counter" — deferred to 1F-layout integration (loader wraps load() in try/catch and increments the metric from 1G-metrics)
**No new dependencies.** Stubs are pure TypeScript with no vendor SDKs.
@@ -1,791 +0,0 @@
# Phase 1G-logger — Logger Runtime Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the runtime logger — console transport (dev), JSON-lines HTTP transport (production), a `createRootLogger()` factory, and a React context provider with `useLogger()` — so that 1F-layout and all downstream features can log structured events with `logger.info("msg", { field: "value" })` in both SSR and client contexts.
**Architecture:** `src/observability/logger/types.ts` already exists (seeded in 1A-1 with `Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport`). This plan adds the runtime implementation: a `LoggerImpl` class that dispatches to pluggable transports, two built-in transports (console for dev, JSON-lines HTTP for production), and a factory + React provider. The logger is designed for request-scoped child loggers on the server (each request gets a child with `{ traceId, locale }` fields) and a single root logger shared on the client.
**Tech Stack:** No new dependencies. The JSON-lines transport uses `fetch` / `navigator.sendBeacon` (browser) or `globalThis.fetch` (Node) — both built-in on Node 24.
**Prerequisites:** 1A-1 (types.ts already shipped), 1A-3 (ESLint boundaries).
---
## File structure
| File | Responsibility | Task |
|---|---|---|
| `src/observability/logger/logger-impl.ts` | `LoggerImpl` class implementing `Logger` | 1 |
| `src/observability/logger/logger-impl.test.ts` | Tests | 1 |
| `src/observability/logger/console-transport.ts` | Dev-mode console transport | 2 |
| `src/observability/logger/console-transport.test.ts` | Tests | 2 |
| `src/observability/logger/json-lines-transport.ts` | Production HTTP transport with batching | 3 |
| `src/observability/logger/json-lines-transport.test.ts` | Tests | 3 |
| `src/observability/logger/root.ts` | `createRootLogger()` factory | 4 |
| `src/observability/logger/root.test.ts` | Tests | 4 |
| `src/observability/logger/provider.tsx` | React context + `useLogger()` | 5 |
---
## Task 1 — TDD `LoggerImpl`
**Files:**
- Create: `src/observability/logger/logger-impl.ts`
- Create: `src/observability/logger/logger-impl.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/observability/logger/logger-impl.test.ts`:
```typescript
import { describe, expect, it, vi } from "vitest";
import type { LogRecord, LogTransport } from "./types.js";
import { LoggerImpl } from "./logger-impl.js";
function mockTransport(): LogTransport & { records: LogRecord[] } {
const records: LogRecord[] = [];
return {
records,
write(record: LogRecord) { records.push(record); },
flush: vi.fn(async () => {}),
};
}
describe("LoggerImpl", () => {
it("writes a record at each log level", () => {
const t = mockTransport();
const logger = new LoggerImpl(t);
logger.debug("d");
logger.info("i");
logger.warn("w");
logger.error("e");
expect(t.records).toHaveLength(4);
expect(t.records.map(r => r.level)).toEqual(["debug", "info", "warn", "error"]);
expect(t.records.map(r => r.msg)).toEqual(["d", "i", "w", "e"]);
});
it("includes fields in the record", () => {
const t = mockTransport();
const logger = new LoggerImpl(t);
logger.info("msg", { userId: 123, action: "click" });
expect(t.records[0]?.fields).toEqual({ userId: 123, action: "click" });
});
it("includes an ISO timestamp", () => {
const t = mockTransport();
const logger = new LoggerImpl(t);
logger.info("msg");
expect(t.records[0]?.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("child() propagates context fields to all records", () => {
const t = mockTransport();
const logger = new LoggerImpl(t);
const child = logger.child({ traceId: "abc", locale: "ru" });
child.info("hello", { extra: true });
expect(t.records[0]?.fields).toEqual({
traceId: "abc",
locale: "ru",
extra: true,
});
});
it("child of child merges contexts", () => {
const t = mockTransport();
const logger = new LoggerImpl(t);
const child1 = logger.child({ traceId: "abc" });
const child2 = child1.child({ requestId: "123" });
child2.info("deep");
expect(t.records[0]?.fields).toEqual({
traceId: "abc",
requestId: "123",
});
});
it("error level includes err field if provided", () => {
const t = mockTransport();
const logger = new LoggerImpl(t);
const err = new Error("boom");
logger.error("failed", { err, op: "fetch" });
const record = t.records[0];
expect(record?.fields?.["op"]).toBe("fetch");
// The err field should be serialized — at minimum the message
expect(record?.fields?.["err"]).toBeDefined();
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
```bash
pnpm test src/observability/logger/logger-impl
```
- [ ] **Step 3: Write implementation**
Create `src/observability/logger/logger-impl.ts`:
```typescript
import type { Logger, LogFields, LogLevel, LogRecord, LogTransport } from "./types.js";
export class LoggerImpl implements Logger {
private readonly transport: LogTransport;
private readonly context: LogFields;
constructor(transport: LogTransport, context: LogFields = {}) {
this.transport = transport;
this.context = context;
}
debug(msg: string, fields?: LogFields): void {
this.write("debug", msg, fields);
}
info(msg: string, fields?: LogFields): void {
this.write("info", msg, fields);
}
warn(msg: string, fields?: LogFields): void {
this.write("warn", msg, fields);
}
error(msg: string, fields?: LogFields & { err?: Error }): void {
const { err, ...rest } = fields ?? {};
const serialized: LogFields = { ...rest };
if (err) {
serialized["err"] = `${err.name}: ${err.message}`;
}
this.write("error", msg, serialized);
}
child(context: LogFields): Logger {
return new LoggerImpl(this.transport, { ...this.context, ...context });
}
private write(level: LogLevel, msg: string, fields?: LogFields): void {
const record: LogRecord = {
ts: new Date().toISOString(),
level,
msg,
fields: { ...this.context, ...fields },
};
this.transport.write(record);
}
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
```bash
pnpm test src/observability/logger/logger-impl
```
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/logger/logger-impl.ts src/observability/logger/logger-impl.test.ts
git commit -m "Add LoggerImpl with transport dispatch and child context propagation"
```
---
## Task 2 — TDD console transport
**Files:**
- Create: `src/observability/logger/console-transport.ts`
- Create: `src/observability/logger/console-transport.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/observability/logger/console-transport.test.ts`:
```typescript
import { describe, expect, it, vi } from "vitest";
import type { LogRecord } from "./types.js";
import { ConsoleTransport } from "./console-transport.js";
describe("ConsoleTransport", () => {
it("pipes debug records to console.debug", () => {
const spy = vi.spyOn(console, "debug").mockImplementation(() => {});
const transport = new ConsoleTransport();
const record: LogRecord = { ts: "2025-01-01T00:00:00Z", level: "debug", msg: "hello", fields: {} };
transport.write(record);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0]?.[0]).toContain("hello");
spy.mockRestore();
});
it("pipes info records to console.info", () => {
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
const transport = new ConsoleTransport();
transport.write({ ts: "2025-01-01T00:00:00Z", level: "info", msg: "info msg", fields: { key: "val" } });
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0]?.[0]).toContain("info msg");
spy.mockRestore();
});
it("pipes warn records to console.warn", () => {
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
const transport = new ConsoleTransport();
transport.write({ ts: "2025-01-01T00:00:00Z", level: "warn", msg: "w", fields: {} });
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
it("pipes error records to console.error", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const transport = new ConsoleTransport();
transport.write({ ts: "2025-01-01T00:00:00Z", level: "error", msg: "e", fields: {} });
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
it("flush is a no-op that resolves immediately", async () => {
const transport = new ConsoleTransport();
await expect(transport.flush()).resolves.toBeUndefined();
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
- [ ] **Step 3: Write implementation**
Create `src/observability/logger/console-transport.ts`:
```typescript
import type { LogRecord, LogTransport } from "./types.js";
/**
* Dev-mode transport that pipes log records to the browser/Node console.
* Each record is printed as `[LEVEL] ts msg {fields}`.
*/
export class ConsoleTransport implements LogTransport {
write(record: LogRecord): void {
const prefix = `[${record.level.toUpperCase()}] ${record.ts}`;
const hasFields = Object.keys(record.fields).length > 0;
const msg = hasFields
? `${prefix} ${record.msg} ${JSON.stringify(record.fields)}`
: `${prefix} ${record.msg}`;
switch (record.level) {
case "debug":
console.debug(msg);
break;
case "info":
console.info(msg);
break;
case "warn":
console.warn(msg);
break;
case "error":
console.error(msg);
break;
}
}
async flush(): Promise<void> {
// Console output is synchronous — nothing to flush.
}
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/logger/console-transport.ts src/observability/logger/console-transport.test.ts
git commit -m "Add dev-mode ConsoleTransport for logger"
```
---
## Task 3 — TDD JSON-lines HTTP transport
**Files:**
- Create: `src/observability/logger/json-lines-transport.ts`
- Create: `src/observability/logger/json-lines-transport.test.ts`
Features: batching (collect N records or wait M ms, whichever comes first), backpressure drop (if buffer exceeds max, drop oldest), redaction of sensitive field names, `sendBeacon` on page unload / `flush()`.
- [ ] **Step 1: Write failing tests**
Create `src/observability/logger/json-lines-transport.test.ts`:
```typescript
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import type { LogRecord } from "./types.js";
import { JsonLinesHttpTransport } from "./json-lines-transport.js";
function record(overrides?: Partial<LogRecord>): LogRecord {
return {
ts: "2025-01-01T00:00:00.000Z",
level: "info",
msg: "test",
fields: {},
...overrides,
};
}
describe("JsonLinesHttpTransport", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
fetchSpy = vi.fn(async () => new Response(null, { status: 200 }));
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("flushes batch after batchSize records", async () => {
const transport = new JsonLinesHttpTransport({
endpoint: "https://logs.example/ingest",
batchSize: 2,
flushIntervalMs: 60000,
maxBufferSize: 100,
fetchImpl: fetchSpy,
});
transport.write(record({ msg: "one" }));
expect(fetchSpy).not.toHaveBeenCalled();
transport.write(record({ msg: "two" }));
// Should have flushed after 2nd record
expect(fetchSpy).toHaveBeenCalledTimes(1);
const body = fetchSpy.mock.calls[0]?.[1]?.body as string;
const lines = body.trim().split("\n");
expect(lines).toHaveLength(2);
});
it("flushes after flushIntervalMs even if batch is not full", async () => {
const transport = new JsonLinesHttpTransport({
endpoint: "https://logs.example/ingest",
batchSize: 100,
flushIntervalMs: 1000,
maxBufferSize: 100,
fetchImpl: fetchSpy,
});
transport.write(record());
expect(fetchSpy).not.toHaveBeenCalled();
vi.advanceTimersByTime(1001);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("drops oldest records when buffer exceeds maxBufferSize", () => {
const transport = new JsonLinesHttpTransport({
endpoint: "https://logs.example/ingest",
batchSize: 100,
flushIntervalMs: 60000,
maxBufferSize: 3,
fetchImpl: fetchSpy,
});
transport.write(record({ msg: "1" }));
transport.write(record({ msg: "2" }));
transport.write(record({ msg: "3" }));
transport.write(record({ msg: "4" }));
transport.write(record({ msg: "5" }));
// Force flush to see what's in the buffer
transport.flush();
const body = fetchSpy.mock.calls[0]?.[1]?.body as string;
const lines = body.trim().split("\n");
expect(lines).toHaveLength(3);
// Should contain the 3 most recent
expect(lines[0]).toContain('"3"');
expect(lines[1]).toContain('"4"');
expect(lines[2]).toContain('"5"');
});
it("redacts sensitive field names", async () => {
const transport = new JsonLinesHttpTransport({
endpoint: "https://logs.example/ingest",
batchSize: 1,
flushIntervalMs: 60000,
maxBufferSize: 100,
redactFields: ["password", "token", "secret"],
fetchImpl: fetchSpy,
});
transport.write(record({
fields: { password: "hunter2", token: "abc123", safe: "visible" },
}));
const body = fetchSpy.mock.calls[0]?.[1]?.body as string;
const parsed = JSON.parse(body.trim());
expect(parsed.fields.password).toBe("[REDACTED]");
expect(parsed.fields.token).toBe("[REDACTED]");
expect(parsed.fields.safe).toBe("visible");
});
it("flush() sends all buffered records and clears the buffer", async () => {
const transport = new JsonLinesHttpTransport({
endpoint: "https://logs.example/ingest",
batchSize: 100,
flushIntervalMs: 60000,
maxBufferSize: 100,
fetchImpl: fetchSpy,
});
transport.write(record({ msg: "a" }));
transport.write(record({ msg: "b" }));
await transport.flush();
expect(fetchSpy).toHaveBeenCalledTimes(1);
// Flush again — nothing to send
await transport.flush();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
- [ ] **Step 3: Write implementation**
Create `src/observability/logger/json-lines-transport.ts`:
```typescript
import type { LogRecord, LogTransport } from "./types.js";
export interface JsonLinesHttpTransportOptions {
endpoint: string;
batchSize?: number;
flushIntervalMs?: number;
maxBufferSize?: number;
redactFields?: string[];
fetchImpl?: typeof fetch;
}
export class JsonLinesHttpTransport implements LogTransport {
private readonly endpoint: string;
private readonly batchSize: number;
private readonly maxBufferSize: number;
private readonly redactFields: Set<string>;
private readonly fetchFn: typeof fetch;
private buffer: LogRecord[] = [];
private timer: ReturnType<typeof setInterval> | null = null;
constructor(options: JsonLinesHttpTransportOptions) {
this.endpoint = options.endpoint;
this.batchSize = options.batchSize ?? 50;
this.maxBufferSize = options.maxBufferSize ?? 500;
this.redactFields = new Set(options.redactFields ?? ["password", "token", "secret", "authorization"]);
this.fetchFn = options.fetchImpl ?? globalThis.fetch;
const intervalMs = options.flushIntervalMs ?? 5000;
this.timer = setInterval(() => {
if (this.buffer.length > 0) {
void this.sendBatch();
}
}, intervalMs);
// Unref so the timer doesn't keep the Node process alive
if (typeof this.timer === "object" && "unref" in this.timer) {
this.timer.unref();
}
}
write(record: LogRecord): void {
const redacted = this.redact(record);
this.buffer.push(redacted);
// Backpressure: drop oldest if buffer exceeds max
while (this.buffer.length > this.maxBufferSize) {
this.buffer.shift();
}
// Flush if batch is full
if (this.buffer.length >= this.batchSize) {
void this.sendBatch();
}
}
async flush(): Promise<void> {
if (this.buffer.length === 0) return;
await this.sendBatch();
}
private async sendBatch(): Promise<void> {
const batch = this.buffer.splice(0, this.buffer.length);
if (batch.length === 0) return;
const body = batch.map((r) => JSON.stringify(r)).join("\n");
try {
await this.fetchFn(this.endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-ndjson" },
body,
});
} catch {
// Silently drop failed sends — logging a log failure causes recursion.
// In production, a metrics counter would track send failures.
}
}
private redact(record: LogRecord): LogRecord {
if (this.redactFields.size === 0) return record;
const fields = { ...record.fields };
for (const key of Object.keys(fields)) {
if (this.redactFields.has(key.toLowerCase())) {
fields[key] = "[REDACTED]";
}
}
return { ...record, fields };
}
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/logger/json-lines-transport.ts src/observability/logger/json-lines-transport.test.ts
git commit -m "Add JsonLinesHttpTransport with batching, backpressure, and redaction"
```
---
## Task 4 — TDD `createRootLogger()` factory
**Files:**
- Create: `src/observability/logger/root.ts`
- Create: `src/observability/logger/root.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/observability/logger/root.test.ts`:
```typescript
import { describe, expect, it, vi, afterEach } from "vitest";
import type { Logger } from "./types.js";
describe("createRootLogger", () => {
afterEach(async () => {
const mod = await import("./root.js");
mod.__resetRootLoggerForTests();
vi.resetModules();
});
it("returns a Logger with console transport in development", async () => {
process.env["NODE_ENV"] = "development";
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
__resetRootLoggerForTests();
const logger = createRootLogger();
expect(logger).toBeDefined();
expect(typeof logger.info).toBe("function");
expect(typeof logger.child).toBe("function");
});
it("returns a Logger with JSON-lines transport in production", async () => {
process.env["NODE_ENV"] = "production";
process.env["LOGS_ENDPOINT"] = "https://logs.example/ingest";
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
__resetRootLoggerForTests();
const logger = createRootLogger();
expect(logger).toBeDefined();
expect(typeof logger.info).toBe("function");
});
it("caches the logger instance (returns same object on repeated calls)", async () => {
process.env["NODE_ENV"] = "development";
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
__resetRootLoggerForTests();
const a = createRootLogger();
const b = createRootLogger();
expect(a).toBe(b);
});
it("child() produces a Logger with merged context", async () => {
process.env["NODE_ENV"] = "development";
const spy = vi.spyOn(console, "info").mockImplementation(() => {});
const { createRootLogger, __resetRootLoggerForTests } = await import("./root.js");
__resetRootLoggerForTests();
const logger = createRootLogger();
const child = logger.child({ traceId: "test-123" });
child.info("hello");
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0]?.[0]).toContain("test-123");
spy.mockRestore();
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
- [ ] **Step 3: Write implementation**
Create `src/observability/logger/root.ts`:
```typescript
import type { Logger, LogTransport } from "./types.js";
import { LoggerImpl } from "./logger-impl.js";
import { ConsoleTransport } from "./console-transport.js";
import { JsonLinesHttpTransport } from "./json-lines-transport.js";
let cached: Logger | undefined;
/**
* Creates or returns the cached root logger. In development, uses
* ConsoleTransport. In other envs, uses JsonLinesHttpTransport if
* LOGS_ENDPOINT is set, otherwise falls back to console.
*/
export function createRootLogger(): Logger {
if (cached) return cached;
const env = process.env["NODE_ENV"] ?? "development";
const logsEndpoint = process.env["LOGS_ENDPOINT"];
let transport: LogTransport;
if (env === "development" || !logsEndpoint) {
transport = new ConsoleTransport();
} else {
transport = new JsonLinesHttpTransport({
endpoint: logsEndpoint,
batchSize: 50,
flushIntervalMs: 5000,
maxBufferSize: 500,
});
}
cached = new LoggerImpl(transport);
return cached;
}
/** Test-only: clears the cached root logger. */
export function __resetRootLoggerForTests(): void {
cached = undefined;
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/logger/root.ts src/observability/logger/root.test.ts
git commit -m "Add createRootLogger factory with transport selection by env"
```
---
## Task 5 — Create `src/observability/logger/provider.tsx`
**Files:**
- Create: `src/observability/logger/provider.tsx`
No TDD — thin React wrapper, exercised by 1F-layout.
- [ ] **Step 1: Write `src/observability/logger/provider.tsx`**
```tsx
import { createContext, useContext } from "react";
import type { ReactNode } from "react";
import type { Logger } from "./types.js";
const LoggerContext = createContext<Logger | null>(null);
export interface LoggerProviderProps {
logger: Logger;
children: ReactNode;
}
/**
* Provides the Logger instance to the React tree. On the server,
* use a request-scoped child logger (with traceId, locale). On the
* client, use the shared root logger from createRootLogger().
*/
export function LoggerProvider({
logger,
children,
}: LoggerProviderProps): JSX.Element {
return (
<LoggerContext.Provider value={logger}>
{children}
</LoggerContext.Provider>
);
}
/**
* Returns the Logger from context. Throws if used outside
* <LoggerProvider>.
*/
export function useLogger(): Logger {
const logger = useContext(LoggerContext);
if (!logger) {
throw new Error(
"useLogger() must be used within a <LoggerProvider>. " +
"Ensure the root layout wraps the tree with <LoggerProvider>.",
);
}
return logger;
}
```
- [ ] **Step 2: Typecheck + lint**
```bash
pnpm typecheck && pnpm lint
```
- [ ] **Step 3: Commit**
```bash
git add src/observability/logger/provider.tsx
git commit -m "Add LoggerProvider React context with useLogger hook"
```
---
## Task 6 — Exit-gate verification
- [ ] **Step 1: All gates**
```bash
pnpm typecheck && pnpm lint && pnpm test
```
Expected: all pass. Test count: 83 (prior) + LoggerImpl + ConsoleTransport + JsonLines + RootLogger = ~100+ total.
- [ ] **Step 2: Git status clean**
```bash
git status
```
---
## Self-review
**Spec coverage.** Master plan §1G-logger:
- types.ts (already shipped in 1A-1) ✓
- `JsonLinesHttpTransport` with batching, backpressure, redaction, flush → Task 3
- `ConsoleTransport` for dev → Task 2
- `createRootLogger()` factory → Task 4
- React context + `useLogger()` → Task 5
- A4-trigger task → documented in master plan, not implemented here (fires on A4 resolution)
- Exit gate tests: batching+flush, redaction, backpressure, console transport, child() → Tasks 1-4
**Type consistency.** `Logger`, `LogFields`, `LogLevel`, `LogRecord`, `LogTransport` all from `./types.js` (seeded in 1A-1).
@@ -1,366 +0,0 @@
# Phase 1G-metrics — OpenTelemetry + Custom Instruments Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the OpenTelemetry runtime — server and browser initializers, `getMeter`/`getTracer` accessors, and the 8 custom metric instruments — so that 1F-layout, 1D, 1E, and all downstream features can emit structured metrics with `flightsApiError.add(1, { route })` in both SSR and client contexts.
**Architecture:** `otel.ts` is the **only** file allowed to import from `@opentelemetry/sdk-metrics` and `@opentelemetry/sdk-node` (enforced by 1A-3 ESLint boundaries). It exports `initServerOtel(env)` and `initBrowserOtel(env)` which wire the real `MeterProvider`/`TracerProvider`. `custom.ts` uses `@opentelemetry/api`'s proxy meter to declare instruments at module level — safe because the proxy lazy-resolves after init runs. `otel.ts` imports `Env` from `@/env` and `Logger` from `@/observability/logger/types`.
**Tech Stack:** `@opentelemetry/api`, `@opentelemetry/sdk-node`, `@opentelemetry/sdk-metrics`, `@opentelemetry/exporter-trace-otlp-http`, `@opentelemetry/exporter-metrics-otlp-http`, `web-vitals`.
**Prerequisites:** 1A-1 (skeleton + `Env` type), 1A-3 (ESLint boundaries), 1G-logger (Logger types).
---
## File structure
| File | Responsibility | Task |
|---|---|---|
| `src/observability/metrics/otel.ts` | OTel init + getMeter/getTracer | 2 |
| `src/observability/metrics/otel.test.ts` | Tests | 2 |
| `src/observability/metrics/custom.ts` | 8 custom metric instruments | 3 |
---
## Task 1 — Install OTel dependencies
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Install dependencies**
```bash
pnpm add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-metrics @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http web-vitals
```
- [ ] **Step 2: Verify installation**
```bash
pnpm typecheck
```
- [ ] **Step 3: Commit**
```bash
git add package.json pnpm-lock.yaml
git commit -m "Add OpenTelemetry and web-vitals dependencies for metrics pipeline"
```
---
## Task 2 — TDD `otel.ts`
**Files:**
- Create: `src/observability/metrics/otel.ts`
- Create: `src/observability/metrics/otel.test.ts`
- [ ] **Step 1: Write failing tests**
Create `src/observability/metrics/otel.test.ts`:
```typescript
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { metrics, trace } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
AggregationTemporality,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
describe("otel", () => {
beforeEach(() => {
// Reset global providers between tests
metrics.disable();
trace.disable();
});
afterEach(() => {
metrics.disable();
trace.disable();
vi.restoreAllMocks();
});
it("initServerOtel registers a MeterProvider and TracerProvider", async () => {
const { initServerOtel } = await import("./otel.js");
initServerOtel({
OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
OTEL_SERVICE_NAME: "flights-test",
NODE_ENV: "test",
} as any);
// After init, getMeter should return a working meter
const { getMeter, getTracer } = await import("./otel.js");
const meter = getMeter("test");
const tracer = getTracer("test");
expect(meter).toBeDefined();
expect(tracer).toBeDefined();
});
it("counter incremented via proxy meter is observable by test reader", async () => {
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 100,
});
const { initServerOtelWithReader } = await import("./otel.js");
initServerOtelWithReader({
OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
OTEL_SERVICE_NAME: "flights-test",
NODE_ENV: "test",
} as any, reader);
const counter = metrics.getMeter("flights").createCounter("test.counter");
counter.add(1, { route: "/smoke" });
// Force a collection cycle
await reader.forceFlush();
const exported = exporter.getMetrics();
expect(exported.length).toBeGreaterThan(0);
const testMetric = exported
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "test.counter");
expect(testMetric).toBeDefined();
await reader.shutdown();
});
it("getMeter returns a meter from @opentelemetry/api", async () => {
const { getMeter } = await import("./otel.js");
const meter = getMeter("my-component");
expect(meter).toBeDefined();
expect(typeof meter.createCounter).toBe("function");
expect(typeof meter.createHistogram).toBe("function");
});
it("getTracer returns a tracer from @opentelemetry/api", async () => {
const { getTracer } = await import("./otel.js");
const tracer = getTracer("my-component");
expect(tracer).toBeDefined();
expect(typeof tracer.startSpan).toBe("function");
});
});
```
- [ ] **Step 2: Run — MUST FAIL**
```bash
pnpm test src/observability/metrics/otel
```
- [ ] **Step 3: Write implementation**
Create `src/observability/metrics/otel.ts`:
```typescript
import { metrics, trace } from "@opentelemetry/api";
import type { Meter, Tracer } from "@opentelemetry/api";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import type { MetricReader } from "@opentelemetry/sdk-metrics";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import type { Env } from "@/env";
import type { Logger } from "@/observability/logger/types";
let initialized = false;
/**
* Initialize OpenTelemetry for the server (Node) process.
* Called once per process at startup.
*/
export function initServerOtel(env: Env): void {
if (initialized) return;
const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT;
const serviceName = env.OTEL_SERVICE_NAME ?? "flights-web";
const metricReader = new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }),
exportIntervalMillis: 15_000,
});
const sdk = new NodeSDK({
serviceName,
traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }),
metricReader,
});
sdk.start();
initialized = true;
}
/**
* Test-only variant that accepts a custom MetricReader for in-memory assertions.
*/
export function initServerOtelWithReader(env: Env, reader: MetricReader): void {
if (initialized) return;
const serviceName = (env as Record<string, string>).OTEL_SERVICE_NAME ?? "flights-test";
const meterProvider = new MeterProvider({
readers: [reader],
});
metrics.setGlobalMeterProvider(meterProvider);
initialized = true;
}
/**
* Initialize OpenTelemetry for the browser.
* Called once per tab via useEffect in the root layout.
* Browser-side uses web-vitals to report CWV as histograms.
*/
export function initBrowserOtel(env: Env): void {
if (initialized) return;
const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT;
if (!endpoint) return;
const meterProvider = new MeterProvider({
readers: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }),
exportIntervalMillis: 30_000,
}),
],
});
metrics.setGlobalMeterProvider(meterProvider);
// Report web-vitals as OTel histograms
const cwvMeter = meterProvider.getMeter("web-vitals");
void import("web-vitals").then(({ onCLS, onFID, onLCP, onFCP, onTTFB }) => {
const cls = cwvMeter.createHistogram("web_vitals.cls");
const fid = cwvMeter.createHistogram("web_vitals.fid");
const lcp = cwvMeter.createHistogram("web_vitals.lcp");
const fcp = cwvMeter.createHistogram("web_vitals.fcp");
const ttfb = cwvMeter.createHistogram("web_vitals.ttfb");
onCLS((m) => cls.record(m.value));
onFID((m) => fid.record(m.value));
onLCP((m) => lcp.record(m.value));
onFCP((m) => fcp.record(m.value));
onTTFB((m) => ttfb.record(m.value));
});
initialized = true;
}
/** Returns a named Meter from the global MeterProvider. */
export function getMeter(name: string): Meter {
return metrics.getMeter(name);
}
/** Returns a named Tracer from the global TracerProvider. */
export function getTracer(name: string): Tracer {
return trace.getTracer(name);
}
```
- [ ] **Step 4: Run — ALL MUST PASS**
```bash
pnpm test src/observability/metrics/otel
```
- [ ] **Step 5: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/metrics/otel.ts src/observability/metrics/otel.test.ts
git commit -m "Add OTel server/browser initializers with getMeter/getTracer accessors"
```
---
## Task 3 — Create `custom.ts` (declarative, no TDD)
**Files:**
- Create: `src/observability/metrics/custom.ts`
- [ ] **Step 1: Write implementation**
Create `src/observability/metrics/custom.ts`:
```typescript
import { metrics } from "@opentelemetry/api";
/**
* Module-level metric instruments for the flights remote component.
* Safe to declare at module scope — @opentelemetry/api's proxy meter
* lazy-resolves to the real MeterProvider after initServerOtel/initBrowserOtel runs.
*/
const meter = metrics.getMeter("flights");
/** SSR request duration histogram (seconds). */
export const flightsSsrRequestDuration = meter.createHistogram("flights.ssr.request.duration");
/** Upstream API request duration histogram (seconds). */
export const flightsApiRequestDuration = meter.createHistogram("flights.api.request.duration");
/** Upstream API error counter (by route, status). */
export const flightsApiError = meter.createCounter("flights.api.error");
/** SignalR active connections gauge. */
export const flightsSignalRConnected = meter.createUpDownCounter("flights.signalr.connected");
/** SignalR messages received counter. */
export const flightsSignalRMessageReceived = meter.createCounter("flights.signalr.message.received");
/** SignalR disconnection counter (by reason). */
export const flightsSignalRDisconnect = meter.createCounter("flights.signalr.disconnect");
/** Feature component render counter (by feature name). */
export const flightsFeatureRender = meter.createCounter("flights.feature.render");
/** Unhandled React error counter (caught by ErrorBoundary). */
export const flightsReactError = meter.createCounter("flights.react.error");
```
- [ ] **Step 2: Typecheck + lint, commit**
```bash
pnpm typecheck && pnpm lint
git add src/observability/metrics/custom.ts
git commit -m "Add 8 custom metric instruments using OTel proxy meter"
```
---
## Task 4 — Exit-gate verification
- [ ] **Step 1: All gates**
```bash
pnpm typecheck && pnpm lint && pnpm test
```
Expected: all pass. OTel init test proves counter is observable via test reader.
- [ ] **Step 2: Git status clean**
```bash
git status
```
---
## Self-review
**Spec coverage.** Master plan §1G-metrics:
- `initServerOtel(env)` / `initBrowserOtel(env)` — Task 2
- `getMeter(name)` / `getTracer(name)` — Task 2
- 8 custom instruments (`flights.ssr.request.duration`, `flights.api.request.duration`, `flights.api.error`, `flights.signalr.connected`, `flights.signalr.message.received`, `flights.signalr.disconnect`, `flights.feature.render`, `flights.react.error`) — Task 3
- web-vitals histograms created inside `initBrowserOtel` — Task 2
- `otel.ts` is the only file importing `@opentelemetry/sdk-metrics` / `@opentelemetry/sdk-node` — enforced by 1A-3 ESLint rule, verified at exit gate
**Import boundary.** `otel.ts` imports `Env` from `@/env` and `Logger` from `@/observability/logger/types`. `custom.ts` imports only from `@opentelemetry/api` (public API, no SDK).
**Type consistency.** `Meter`, `Tracer` from `@opentelemetry/api`. `Env` from `@/env` (seeded in 1A-1).
@@ -1,139 +0,0 @@
# Phase 1H — Security Hardening
**Parent:** Phase 1 Foundation Master Plan
**Branch:** `plan/react-rewrite`
## Overview
Implements security hardening contracts: CSP nonce middleware, SSR stream nonce injection, standard security headers, and safe storage abstraction with Zod schema validation.
## Constraints
- Do NOT touch `ClientApp/`, ASP.NET, `wwwroot/`
- Do NOT modify `modern.config.ts` (1I wires middleware registrations)
- `zod` already installed (from 1A-1)
- Middleware exported as factory functions, not auto-registered
## Tasks
### Task 1: `src/shared/storage.ts` + tests (TDD)
**Files:**
- `src/shared/storage.ts`
- `src/shared/storage.test.ts`
**Contract:**
```ts
import type { ZodSchema } from "zod";
export const storage: {
get<T>(key: string, schema: ZodSchema<T>): T | null;
set<T>(key: string, value: T, schema: ZodSchema<T>): void;
delete(key: string): void;
clear(): void;
};
```
**Details:**
- All keys namespaced with `afl_` prefix
- `get` returns `null` when key missing, JSON parse fails, or Zod validation fails (never throws)
- `set` validates against schema before writing (throws on validation failure)
- `delete` removes the namespaced key
- `clear` removes only `afl_`-prefixed keys (not all storage)
**Tests:**
- get/set round-trip with valid schema
- get returns null for missing key
- get returns null when stored value fails schema validation
- set throws when value doesn't match schema
- delete removes the key
- clear removes only namespaced keys
- keys are stored with `afl_` prefix
### Task 2: `src/server/middleware/csp.ts` + tests (TDD)
**Files:**
- `src/server/middleware/csp.ts`
- `src/server/middleware/csp.test.ts`
**Contract:**
```ts
import { createContext } from "react";
export interface CspMiddlewareOptions {
reportOnly?: boolean;
}
export function cspMiddleware(options?: CspMiddlewareOptions): (req: unknown, res: { setHeader(name: string, value: string): void }, next: () => void) => void;
export const CspNonceContext: React.Context<string>; // default ""
```
**Details:**
- Generates per-request nonce using `crypto.randomUUID()`
- Sets `Content-Security-Policy` header with `script-src 'nonce-{nonce}'`
- When `reportOnly: true`, uses `Content-Security-Policy-Report-Only` header
- Exposes nonce via `CspNonceContext` (default `""` on client)
- Nonce attached to request object for downstream middleware access
**Tests:**
- Generates unique nonce per call
- Sets CSP header with nonce
- reportOnly option uses report-only header
- Each invocation produces a different nonce
- CspNonceContext has default value of ""
### Task 3: `src/server/middleware/nonce-stream-transform.ts` + tests (TDD)
**Files:**
- `src/server/middleware/nonce-stream-transform.ts`
- `src/server/middleware/nonce-stream-transform.test.ts`
**Contract:**
```ts
export function wrapSsrStreamWithNonce(
stream: NodeJS.ReadableStream,
nonce: string,
): NodeJS.ReadableStream;
```
**Details:**
- Processes SSR HTML stream to inject `nonce="..."` on `<script>` tags without a nonce
- Does NOT double-inject on `<script nonce="...">` tags
- Handles `<script>`, `<script src="...">`, `<script type="module">` etc.
- Must handle chunks that split across tag boundaries
**Tests:**
- Injects nonce on bare `<script>` tags
- Injects nonce on `<script src="...">` tags
- Does not double-inject on `<script nonce="existing">` tags
- Handles multiple script tags in one chunk
- Handles chunks split mid-tag
### Task 4: `src/server/middleware/security-headers.ts` (no TDD)
**Files:**
- `src/server/middleware/security-headers.ts`
**Contract:**
```ts
export function securityHeadersMiddleware(): (req: unknown, res: { setHeader(name: string, value: string): void }, next: () => void) => void;
```
**Headers set:**
- `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: SAMEORIGIN`
- `Referrer-Policy: strict-origin-when-cross-origin`
- `Permissions-Policy: geolocation=(), camera=(), microphone=()`
- `Cross-Origin-Opener-Policy: same-origin`
- `Cross-Origin-Resource-Policy: cross-origin`
## Exit Gate
- `pnpm typecheck` passes
- `pnpm lint` passes
- `pnpm test` passes (all new + existing tests)
- `storage.get` with mismatching schema returns `null`
- CSP middleware generates unique nonce per request
- Every `<script>` in nonce-stream-transform output carries the nonce
@@ -1,106 +0,0 @@
# Phase 1I — Deploy pipeline + runbook contracts
**Parent plan:** `2026-04-14-phase-1-foundation-master.md`, section "1I — Deploy pipeline + runbook contracts"
**Date:** 2026-04-14
---
## Overview
This sub-plan delivers the deploy pipeline contracts: Docker images for both build targets (standalone SSR and remote static), health endpoint, graceful shutdown handler, CI workflow template, and an operational runbook.
## Constraints
- Do NOT modify `modern.config.ts` (middleware registration is a future integration step)
- Health and shutdown modules export factory functions, not auto-registered middleware
- Coexist with the existing ASP.NET `Dockerfile` and `Dockerfile.local` (write `Dockerfile.react` and `Dockerfile.remote`)
- Do NOT touch `ClientApp/`, ASP.NET files, or `wwwroot/`
---
## Tasks
### Task 1 — `Dockerfile.react` (standalone SSR image)
**File:** `Dockerfile.react` (repo root)
Multi-stage build:
1. Stage 1 (`deps`): Node 24 base, enable corepack pnpm, copy `package.json` + `pnpm-lock.yaml`, run `pnpm install --frozen-lockfile`
2. Stage 2 (`build`): copy `src/`, config files, run `pnpm build:standalone`
3. Stage 3 (`runtime`): Node 24 slim, copy `dist/standalone/`, entrypoint `node dist/standalone/index.js`
**Exit gate:** File exists, valid Dockerfile syntax.
### Task 2 — `Dockerfile.remote` (nginx static image)
**File:** `Dockerfile.remote` (repo root)
Multi-stage build:
1. Stage 1 (`deps`): same as Task 1
2. Stage 2 (`build`): copy source, run `pnpm build:remote`
3. Stage 3 (`runtime`): `nginx:alpine`, copy `dist/remote/` to `/usr/share/nginx/html`, expose port 80
**Exit gate:** File exists, valid Dockerfile syntax.
### Task 3 — Health endpoint (`src/server/routes/health.ts`)
**Files:** `src/server/routes/health.ts`, `src/server/routes/health.test.ts`
Export `healthMiddleware(options)` factory function that returns an Express-style `(req, res, next)` handler. The middleware:
- Pings the upstream API client on each request (with configurable timeout, default 5000ms)
- Tracks last successful ping timestamp
- Returns 200 `{ status: "ok" }` if last success < 60s ago
- Returns 503 `{ status: "degraded", reason: "upstream_unreachable" }` otherwise
**TDD:** Tests mock ApiClient, verify 200/503 responses, verify timeout behavior.
**Exit gate:** `pnpm test` passes health tests.
### Task 4 — Graceful shutdown (`src/server/shutdown.ts`)
**Files:** `src/server/shutdown.ts`, `src/server/shutdown.test.ts`
Export `registerGracefulShutdown(options)` factory function that:
- Registers a SIGTERM handler
- On SIGTERM: calls `server.close()`, waits up to `drainTimeoutMs` (default 30000), flushes logger transport, exits with code 0
- If drain times out, force-exits with code 1
**TDD:** Tests mock `process.on`, `server.close`, `logger` flush; verify shutdown sequence.
**Exit gate:** `pnpm test` passes shutdown tests.
### Task 5 — CI deploy workflow (`.github/workflows/deploy.yml`)
**File:** `.github/workflows/deploy.yml`
Template workflow triggered on push to `main`:
- Checkout, setup Node 24, install pnpm, `pnpm install --frozen-lockfile`
- Build both targets (`pnpm build:both`)
- Build Docker images (`Dockerfile.react`, `Dockerfile.remote`)
- Push to registry (placeholder)
- Deploy to testing environment (placeholder)
**Exit gate:** File exists, valid YAML syntax.
### Task 6 — Operational runbook (`docs/superpowers/phase-1/runbook.md`)
**File:** `docs/superpowers/phase-1/runbook.md`
Covers:
1. Incident response decision tree
2. Canary rollout procedure
3. Rollback procedure (auto + manual)
4. Health-check interpretation
5. Log query cookbook
6. Known-failure playbooks (6 scenarios)
**Exit gate:** File exists, covers all required sections.
---
## Verification
After all tasks:
```bash
pnpm typecheck && pnpm lint && pnpm test
```
@@ -1,896 +0,0 @@
# Phase 2 — Online Board MASTER Plan
> **This document is a plan INDEX, not an executable plan.** It lists the Phase 2 sub-plans, their dependency order, the contracts each sub-plan exports for downstream sub-plans to consume, and the shared files that cross sub-plan boundaries.
>
> **Do not execute this document directly.** Each sub-plan is a separate file under `docs/superpowers/plans/` with its own TDD-granular tasks. They are written on demand by re-invoking the `superpowers:writing-plans` skill with a sub-plan-specific prompt.
**Goal of Phase 2:** Port the Online Board feature from Angular to React, achieving URL parity (100% against the Phase 0 corpus), SEO parity (enhanced with JSON-LD `Flight` + `ItemList` schemas), and live SignalR updates via the `TrackerHub`. Online Board is the hardest feature (SignalR live data + deep-linked search + flight details + real-time UI + SEO) and is deliberately first so its risk surfaces early.
**Phase 2 exit gate** (must pass before Phase 3 starts):
- URL parity 100% verified against the Phase 0 prod-access-log corpus — every `onlineboard/*` URL shape round-trips through `parseOnlineBoardUrl` / `buildOnlineBoardUrl` identically to Angular.
- SEO parity: canonical, hreflang (9 langs + `x-default`), OG tags, JSON-LD (`Flight` for details, `ItemList` of `Flight` for search results) — validated by SSR render + `cheerio` parse + `schema-dts` type check.
- Playwright integration tests passing (4 ported Cypress scenarios + SignalR mock server + error cases).
- VRT within threshold for all online-board routes x 3 viewports (375px, 768px, 1440px) x 2 languages (ru, en).
- Load test at 150 RPS passes on online-board routes (50% headroom above the 100 RPS requirement).
- All Phase 1 exit gates still green (regression gate).
- WCAG AA violations block (upgraded from Phase 1's warn-only).
- Real analytics vendors (Yandex.Metrica, CTM, Variocube, Dynatrace) emitting in `testing` + `staging` environments.
**Reference spec:** `docs/superpowers/specs/2026-04-14-aeroflot-flights-react-rewrite-design.md` — Phase 2 implements §9.2 (Phase 2 scope), §3.3-3.5 (routing + URL parity), §4.4 (SignalR), §5 (UI adapter), §6.5-6.8 (SEO/JSON-LD/OG/hreflang).
**Phase 1 prerequisite:** All Phase 1 exit gates must be green before Phase 2 starts. Phase 2 consumes the following Phase 1 contracts: `ApiClient` + `CachedApiClient` (1D), `SignalRConnection` + `useLiveFlights` (1E), `SeoHead` + `buildHreflangSet` + `JsonLdRenderer` (1F-seo), `createI18nInstance` + `useTranslation` (1C), `Logger` + `useLogger` (1G-logger), `Analytics` + `useAnalytics` (1G-analytics), `ErrorBoundary` + `errorToResponse` (1F-layout), `getEnv` (1A-1), root + locale layouts (1F-layout).
---
## Sub-plan inventory
| ID | Sub-plan | Estimated size | File |
|---|---|---|---|
| **2A** | UI adapter layer (`src/ui/flights/`) | Large (20-30 tasks) | `2026-04-14-phase-2a-ui-flights.md` (TBW) |
| **2B** | URL serializer/parser (`src/features/online-board/url.ts`) | Small (5-10 tasks) | `2026-04-14-phase-2b-url-serializer.md` (TBW) |
| **2C** | API client + hooks (`src/features/online-board/api.ts`, hooks) | Medium (10-20 tasks) | `2026-04-14-phase-2c-api-hooks.md` (TBW) |
| **2D** | SignalR wiring | Medium (10-15 tasks) | `2026-04-14-phase-2d-signalr-wiring.md` (TBW) |
| **2E** | Routes + pages | Medium (15-20 tasks) | `2026-04-14-phase-2e-routes-pages.md` (TBW) |
| **2F** | SEO + JSON-LD | Small (8-12 tasks) | `2026-04-14-phase-2f-seo-jsonld.md` (TBW) |
| **2G** | Parity harnesses | Medium (10-15 tasks) | `2026-04-14-phase-2g-parity-harnesses.md` (TBW) |
| **2H** | Integration tests | Medium (10-15 tasks) | `2026-04-14-phase-2h-integration-tests.md` (TBW) |
Sizes: **Small** = 5-12 tasks, **Medium** = 10-20 tasks, **Large** = 20-30 tasks.
---
## Dependency graph
```
┌───────────────────┐
│ 2A UI adapter │ (flight-display components needed by pages)
│ src/ui/flights/ │
└────────┬──────────┘
┌───────────────┼───────────────┐
│ │ │
▼ ▼ │
┌──────────┐ ┌───────────┐ │
│ 2B URL │ │ 2C API │ │
│ serializer│ │ client + │ │
│ (indepen-│ │ hooks │ │
│ dent) │ │ (uses 2A │ │
│ │ │ for types)│ │
└────┬─────┘ └─────┬─────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ 2D SignalR │ │
│ │ wiring │ │
│ │ (consumes │ │
│ │ 2C hooks) │ │
│ └─────┬──────┘ │
│ │ │
└──────────────┼───────────────┘
┌───────────────────┐
│ 2E Routes + │
│ pages │
│ (consumes 2A + │
│ 2B + 2C + 2D) │
└────────┬──────────┘
┌───────────────────┐
│ 2F SEO + │
│ JSON-LD │
│ (consumes 2E │
│ route context) │
└────────┬──────────┘
┌───────────────────┐
│ 2G Parity │
│ harnesses │
│ (consumes 2E + │
│ 2F for baselines)│
└────────┬──────────┘
┌───────────────────┐
│ 2H Integration │
│ tests │
│ (consumes all) │
└───────────────────┘
```
### Execution order
**Serial (1 engineer):** 2A → 2B → 2C → 2D → 2E → 2F → 2G → 2H.
Rationale:
- 2A must come first: the UI flight-display components are needed by every page. This is the `src/ui/flights/` population step from design spec §5.
- 2B (URL serializer) is logically independent of 2A but ordered after it in serial because 2E needs both and 2A is larger / higher risk.
- 2C depends on 2A for data model types (flight types used in hooks).
- 2D depends on 2C (wires SignalR push events into the same hooks/state that 2C creates).
- 2E depends on 2A + 2B + 2C + 2D — it's the integration point (pages import UI components, use URL parsing, call API hooks, wire live data).
- 2F depends on 2E (SEO builders need the route context and data shapes that pages define).
- 2G depends on 2E + 2F (parity harnesses test the rendered pages + SEO output).
- 2H depends on everything (Playwright integration tests exercise the full feature).
**Parallel (2+ engineers):** After 2A ships:
- **Engineer 1:** 2B (URL serializer, fully independent)
- **Engineer 2:** 2C (API hooks, needs 2A types)
- Then 2D follows 2C; 2E follows 2B + 2C + 2D; rest is serial.
### Critical path
**2A → 2C → 2D → 2E → 2F → 2G → 2H** is the critical path. 2B sits off the critical path (it's small and independent) and can be slotted alongside 2C.
---
## Contracts — what each sub-plan exports
### 2A — UI adapter layer contracts
**Scope:** Port the subset of Angular `FlightsModule` shared components that the Online Board feature uses. These land in `src/ui/flights/` per the design spec §5 location rule ("does more than one feature use it?"). Feature-specific components land in `src/features/online-board/components/` in sub-plan 2E.
**Exports:**
- **`src/ui/flights/FlightCard.tsx`** — single flight row in search results. Displays carrier logo, flight number, departure/arrival airports and times, status badge, aircraft type. Props-driven, no data fetching.
- **`src/ui/flights/FlightList.tsx`** — scrollable list of `FlightCard` items with empty-state and loading skeleton.
- **`src/ui/flights/FlightDetails.tsx`** — expanded flight details view: route map placeholder, status timeline, departure/arrival info, aircraft info, codeshare info.
- **`src/ui/flights/StatusBadge.tsx`** — flight status indicator (on time, delayed, cancelled, landed, etc.) with color-coded styling.
- **`src/ui/flights/AirportDisplay.tsx`** — airport name + IATA code display with optional city name.
- **`src/ui/flights/TimeDisplay.tsx`** — time formatting component (scheduled vs actual, with delay indicator).
- **`src/ui/flights/SearchForm.tsx`** — online board search form with flight number input, airport autocomplete (PrimeReact Autocomplete), date picker (PrimeReact Calendar), and search type selector.
- **`src/ui/flights/CalendarStrip.tsx`** — horizontal date selector showing available search dates from the calendar API.
- **`src/ui/flights/FlightDetailsSkeleton.tsx`** — Suspense fallback skeleton for the details page.
- **`src/ui/flights/FlightListSkeleton.tsx`** — Suspense fallback skeleton for search result pages.
- **`src/ui/flights/ConnectionStatusBadge.tsx`** — SignalR connection status indicator ("live", "reconnecting", "offline").
- **`src/ui/flights/Breadcrumbs.tsx`** — breadcrumb navigation for online board routes.
**Porting workflow:** per design spec §5.4 — read Angular source, translate template to JSX preserving DOM + class names, translate logic to hooks/props, port SCSS to `.module.scss`, write Vitest test, capture VRT baseline.
**SCSS files:** Each component has a co-located `.module.scss` file ported from the Angular component's SCSS. Class names preserved for VRT pixel parity.
**TypeScript contracts (data model types in `src/ui/flights/types.ts`):**
```ts
/** Simplified flight record for list/card display */
export interface ISimpleFlight {
id: string; // unique flight identifier
flightNumber: string; // e.g. "SU 100"
carrier: string; // IATA carrier code, e.g. "SU"
carrierName: string; // localized carrier name
departure: IAirportTime;
arrival: IAirportTime;
status: FlightStatus;
aircraftType?: string;
codeshares?: string[];
distance?: number;
}
export interface IAirportTime {
airport: string; // IATA code
airportName: string; // localized name
cityName: string; // localized city name
scheduled: string; // ISO 8601 datetime
actual?: string; // ISO 8601 datetime (if available)
terminal?: string;
gate?: string;
}
export type FlightStatus =
| "scheduled"
| "delayed"
| "departed"
| "in_flight"
| "landed"
| "arrived"
| "cancelled"
| "diverted"
| "unknown";
/** Parsed flight identifier from URL */
export interface IParsedFlightId {
carrier: string; // e.g. "SU"
flightNumber: string; // e.g. "100"
suffix?: string; // optional flight suffix
date: string; // yyyyMMdd
}
/** Request type discriminator for online board search */
export type FlightRequestType =
| "flight" // search by flight number
| "departure" // search by departure airport
| "arrival" // search by arrival airport
| "route"; // search by departure + arrival airports
```
**Package additions (2A):** `primereact` (PrimeReact component library — specific components: Calendar, Autocomplete, Tooltip), `clsx` (conditional class names).
**Exit gate for 2A:**
- Every UI component has a Vitest test rendering it with representative props and asserting key DOM structure.
- SCSS modules compile with no errors.
- No direct `primereact/*` imports outside `src/ui/` (enforced by 1A-3 ESLint boundary rules).
- VRT baselines captured for each component at 375px, 768px, 1440px viewports.
- `pnpm typecheck` and `pnpm lint` green.
---
### 2B — URL serializer/parser contracts
**Scope:** TDD port of the Angular URL builder/parser for all 6 online board route shapes. Byte-exact parity with Angular's `OnlineBoardFlightNumberUrlParamsResolver`, `OnlineBoardDepartureUrlParamsResolver`, `OnlineBoardArrivalUrlParamsResolver`, `OnlineBoardRouteUrlParamsResolver`. The Phase 0 URL corpus fixtures are the test oracle.
**Exports (`src/features/online-board/url.ts`):**
```ts
import type { FlightRequestType, IParsedFlightId } from "@/ui/flights/types";
/** Discriminated union of all parsed online board URL parameter shapes */
export type OnlineBoardParams =
| { type: "start" }
| { type: "flight"; carrier: string; flightNumber: string; suffix?: string; date: string }
| { type: "departure"; station: string; date: string; timeFrom?: string; timeTo?: string }
| { type: "arrival"; station: string; date: string; timeFrom?: string; timeTo?: string }
| { type: "route"; departure: string; arrival: string; date: string; timeFrom?: string; timeTo?: string }
| { type: "details"; carrier: string; flightNumber: string; suffix?: string; date: string };
/**
* Parse a raw URL path segment into typed online board params.
* Returns null if the path does not match any known online board URL shape.
*
* @param routeType - the route prefix ("flight", "departure", "arrival", "route", or "" for details)
* @param params - the raw URL parameter string (e.g. "SU100-20250115")
*/
export function parseOnlineBoardUrl(routeType: string, params: string): OnlineBoardParams | null;
/**
* Build a URL path segment from typed online board params.
* Output is byte-exact match with Angular's URL builder.
*
* @returns the path segment without leading slash (e.g. "flight/SU100-20250115")
*/
export function buildOnlineBoardUrl(params: OnlineBoardParams): string;
/**
* Parse a flight URL parameter string into its constituent parts.
* Handles format: {carrier}{flightNumber}{suffix?}-{yyyyMMdd}
*/
export function parseFlightUrlParams(raw: string): IParsedFlightId | null;
/**
* Build a flight URL parameter string from parts.
* Output format: {carrier}{flightNumber}{suffix?}-{yyyyMMdd}
*/
export function buildFlightUrlParams(id: IParsedFlightId): string;
/**
* Parse a station URL parameter string.
* Handles format: {station}-{yyyyMMdd}[-{timeFrom}{timeTo}]
*/
export function parseStationUrlParams(raw: string): {
station: string;
date: string;
timeFrom?: string;
timeTo?: string;
} | null;
/**
* Parse a route URL parameter string.
* Handles format: {dep}-{arr}-{yyyyMMdd}[-{timeFrom}{timeTo}]
*/
export function parseRouteUrlParams(raw: string): {
departure: string;
arrival: string;
date: string;
timeFrom?: string;
timeTo?: string;
} | null;
```
**Test strategy:**
1. Table-driven tests against the Phase 0 URL corpus (every real URL from prod access logs).
2. Round-trip property tests: `buildOnlineBoardUrl(parseOnlineBoardUrl(type, params)) === originalParams` for all valid inputs.
3. `fast-check` fuzz tests: random carrier codes (2 chars), flight numbers (1-4 digits), IATA codes (3 chars), dates (valid yyyyMMdd range), optional suffixes.
4. Edge cases: missing optional time range, malformed dates, unknown carriers, extra hyphens.
**Exit gate for 2B:**
- 100% of Phase 0 URL corpus fixtures pass round-trip parity.
- Fuzz tests with `fast-check` find no serialization asymmetry.
- `parseOnlineBoardUrl` returns `null` for invalid inputs (never throws).
- Zero `any` types in the module.
---
### 2C — API client + hooks contracts
**Scope:** Online board REST endpoints wrapped in typed functions + React hooks. Consumes `ApiClient` / `CachedApiClient` from Phase 1's 1D.
**Exports (`src/features/online-board/api.ts`):**
```ts
import type { ISimpleFlight, IParsedFlightId, FlightRequestType } from "@/ui/flights/types";
/** Response shape from GET /board */
export interface BoardResponse {
flights: ISimpleFlight[];
totalCount: number;
date: string;
requestType: FlightRequestType;
}
/** Full flight details response from GET /onlineboard/details */
export interface FlightDetailsResponse {
flight: ISimpleFlight;
route: IRoutePoint[];
codeshares: ICodeshare[];
statusHistory: IStatusHistoryEntry[];
}
export interface IRoutePoint {
airport: string;
airportName: string;
cityName: string;
scheduledTime: string;
actualTime?: string;
terminal?: string;
gate?: string;
}
export interface ICodeshare {
carrier: string;
carrierName: string;
flightNumber: string;
}
export interface IStatusHistoryEntry {
status: string;
timestamp: string;
}
/** Calendar days response from GET /v1/days/.../board/ */
export type CalendarDaysResponse = string[]; // array of "yyyy-MM-dd" date strings
/**
* Search flights on the online board.
* Maps to: GET /board?type={type}&station={station}&date={date}&...
*/
export function searchFlights(
client: ApiClient,
params: {
type: FlightRequestType;
date: string;
station?: string;
departure?: string;
arrival?: string;
carrier?: string;
flightNumber?: string;
timeFrom?: string;
timeTo?: string;
},
): Promise<BoardResponse>;
/**
* Get flight details.
* Maps to: GET /onlineboard/details?flightId={carrier}{number}&date={date}
*/
export function getFlightDetails(
client: ApiClient,
id: IParsedFlightId,
): Promise<FlightDetailsResponse>;
/**
* Get available calendar days for a given search context.
* Maps to: GET /v1/days/{station|route}/board/
*/
export function getCalendarDays(
client: ApiClient,
params: {
type: FlightRequestType;
station?: string;
departure?: string;
arrival?: string;
},
): Promise<CalendarDaysResponse>;
```
**Exports (`src/features/online-board/hooks/useOnlineBoard.ts`):**
```ts
import type { BoardResponse } from "../api";
import type { OnlineBoardParams } from "../url";
export interface UseOnlineBoardResult {
data: BoardResponse;
calendarDays: string[];
isRefetching: boolean;
error: Error | null;
refetch: () => void;
}
/**
* Hook for online board search pages.
* SSR: receives initialData from the loader.
* Client: re-fetches on param change, receives live updates from SignalR (via 2D).
*/
export function useOnlineBoard(
params: OnlineBoardParams,
initialData: BoardResponse,
initialCalendarDays: string[],
): UseOnlineBoardResult;
```
**Exports (`src/features/online-board/hooks/useFlightDetails.ts`):**
```ts
import type { FlightDetailsResponse } from "../api";
import type { IParsedFlightId } from "@/ui/flights/types";
export interface UseFlightDetailsResult {
data: FlightDetailsResponse;
isRefetching: boolean;
error: Error | null;
refetch: () => void;
}
/**
* Hook for the flight details page.
* SSR: receives initialData from the loader.
* Client: re-fetches on param change, receives live updates from SignalR (via 2D).
*/
export function useFlightDetails(
id: IParsedFlightId,
initialData: FlightDetailsResponse,
): UseFlightDetailsResult;
```
**Caching strategy (per design spec §4.2):**
- `searchFlights`: 30s TTL (live data), server LRU + client memory cache via `CachedApiClient`.
- `getFlightDetails`: 30s TTL (live data), same caching layers.
- `getCalendarDays`: 5 min TTL (static reference data).
**Exit gate for 2C:**
- Vitest tests cover: successful API call + response deserialization for all three endpoints; error mapping (404 → `ApiHttpError`, timeout → `ApiTimeoutError`); cache hit for repeated identical queries; hooks render with initial data and update on refetch.
- `useOnlineBoard` and `useFlightDetails` hooks tested with `@testing-library/react-hooks` for SSR initial data pass-through and client-side refetch behavior.
- Zero `any` types.
---
### 2D — SignalR wiring contracts
**Scope:** Connect the generic `useLiveFlights` hook from Phase 1's 1E to the real TrackerHub channels used by Online Board. Wire into the search and details hooks from 2C.
**Exports (`src/features/online-board/hooks/useLiveBoard.ts`):**
```ts
import type { ConnectionStatus } from "@/shared/signalr/connection";
import type { BoardResponse } from "../api";
export interface UseLiveBoardResult {
data: BoardResponse;
connectionStatus: ConnectionStatus;
}
/**
* Wraps useLiveFlights with Online Board-specific channel configuration.
* Channel: SubscribeDate(date, departure?, arrival?)
* On RefreshDate push: triggers silent re-fetch of searchFlights().
*/
export function useLiveBoard(
params: { date: string; departure?: string; arrival?: string },
initialData: BoardResponse,
): UseLiveBoardResult;
```
**Exports (`src/features/online-board/hooks/useLiveFlightDetails.ts`):**
```ts
import type { ConnectionStatus } from "@/shared/signalr/connection";
import type { FlightDetailsResponse } from "../api";
import type { IParsedFlightId } from "@/ui/flights/types";
export interface UseLiveFlightDetailsResult {
data: FlightDetailsResponse;
connectionStatus: ConnectionStatus;
}
/**
* Wraps useLiveFlights with flight details-specific channel configuration.
* Channel: Subscribe(flightId@date)
* On push: triggers silent re-fetch of getFlightDetails().
*/
export function useLiveFlightDetails(
id: IParsedFlightId,
initialData: FlightDetailsResponse,
): UseLiveFlightDetailsResult;
```
**TrackerHub channel mapping:**
- Search pages: `SubscribeDate(date, departure?, arrival?)` — server pushes `RefreshDate` when any flight matching the query updates.
- Details page: `Subscribe({carrier}{flightNumber}@{date})` — server pushes updates when the specific flight changes.
- On push: the hook triggers a silent re-fetch of the corresponding REST endpoint (no full-page reload, no flash). The re-fetched data replaces the current state atomically.
**Integration with 2C hooks:** `useOnlineBoard` (2C) internally delegates to `useLiveBoard` (2D) for its live data; `useFlightDetails` (2C) internally delegates to `useLiveFlightDetails` (2D). The 2C hooks are the public API; 2D hooks are internal wiring.
**Exit gate for 2D:**
- Vitest: mock SignalR hub pushes `RefreshDate``useLiveBoard` triggers re-fetch and returns updated data.
- Vitest: mock SignalR hub pushes flight update → `useLiveFlightDetails` triggers re-fetch.
- Strict Mode double-mount: exactly one `HubConnection.start()` call (inherits from 1E's guarantee).
- Disconnect scenario: `connectionStatus` transitions to `"offline"`, data remains last-known-good.
- SSR: returns `{ data: initialData, connectionStatus: "idle" }` without importing `@microsoft/signalr`.
---
### 2E — Routes + pages contracts
**Scope:** All `src/routes/[lang]/onlineboard/*` route files with loaders, Suspense, `React.lazy`. Also includes feature-specific components in `src/features/online-board/components/` that are not shared across features.
**Route files:**
| Route file | URL pattern | Angular equivalent |
|---|---|---|
| `src/routes/[lang]/onlineboard/page.tsx` | `/{lang}/onlineboard` | Start page (search form) |
| `src/routes/[lang]/onlineboard/flight/[params]/page.tsx` | `/{lang}/onlineboard/flight/SU100-20250115` | Flight number search |
| `src/routes/[lang]/onlineboard/departure/[params]/page.tsx` | `/{lang}/onlineboard/departure/SVO-20250115` | Departure station search |
| `src/routes/[lang]/onlineboard/arrival/[params]/page.tsx` | `/{lang}/onlineboard/arrival/JFK-20250115` | Arrival station search |
| `src/routes/[lang]/onlineboard/route/[params]/page.tsx` | `/{lang}/onlineboard/route/SVO-JFK-20250115` | Route search |
| `src/routes/[lang]/onlineboard/[params]/page.tsx` | `/{lang}/onlineboard/SU100-20250115` | Flight details |
**Feature-specific components (`src/features/online-board/components/`):**
- **`OnlineBoardStart.tsx`** — start page with search form, search history, and popular routes.
- **`OnlineBoardSearch.tsx`** — search results page (shared by flight/departure/arrival/route searches) with flight list, calendar strip, connection status badge, and filter controls.
- **`OnlineBoardDetails.tsx`** — flight details page with full flight info, status timeline, route points, codeshare info, and connection status badge.
**Loader pattern (per design spec §3.4):**
```tsx
// Example: routes/[lang]/onlineboard/departure/[params]/page.tsx
import { lazy, Suspense } from "react";
const OnlineBoardSearch = lazy(() =>
import("@/features/online-board").then(m => ({ default: m.OnlineBoardSearch }))
);
export async function loader({ params }: { params: { lang: string; params: string } }) {
const parsed = parseOnlineBoardUrl("departure", params.params);
if (!parsed || parsed.type !== "departure") throw new ApiHttpError("Not found", 404);
const [data, calendarDays] = await Promise.all([
searchFlights(apiClient, { type: "departure", station: parsed.station, date: parsed.date, timeFrom: parsed.timeFrom, timeTo: parsed.timeTo }),
getCalendarDays(apiClient, { type: "departure", station: parsed.station }),
]);
const seo = buildOnlineBoardSeo(parsed, cityNames);
return { data, calendarDays, seo, parsed };
}
export default function Page() {
const { data, calendarDays, seo, parsed } = useLoaderData<typeof loader>();
return (
<>
<SeoHead {...seo} />
<Suspense fallback={<FlightListSkeleton />}>
<OnlineBoardSearch initialData={data} initialCalendarDays={calendarDays} params={parsed} />
</Suspense>
</>
);
}
```
**Feature barrel (`src/features/online-board/index.ts`) — populated in 2E:**
```ts
// Public surface of the online-board feature
export { OnlineBoardStart } from "./components/OnlineBoardStart";
export { OnlineBoardSearch } from "./components/OnlineBoardSearch";
export { OnlineBoardDetails } from "./components/OnlineBoardDetails";
export { parseOnlineBoardUrl, buildOnlineBoardUrl } from "./url";
export { searchFlights, getFlightDetails, getCalendarDays } from "./api";
export { buildOnlineBoardSeo } from "./seo";
export type { BoardResponse, FlightDetailsResponse, CalendarDaysResponse } from "./api";
export type { OnlineBoardParams } from "./url";
```
**MF expose (`src/mf/expose/OnlineBoard.tsx`) — updated from stub to real in 2E:**
```tsx
import { lazy, Suspense } from "react";
import type { HostContract } from "@/host-contract";
const OnlineBoardFeature = lazy(() =>
import("@/features/online-board").then(m => ({ default: m.OnlineBoardRoot }))
);
export default function OnlineBoard({ hostContract }: { hostContract: HostContract }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<OnlineBoardFeature hostContract={hostContract} />
</Suspense>
);
}
```
This requires an `OnlineBoardRoot` component exported from the feature barrel that wraps the internal router for embedded MF usage.
**Exit gate for 2E:**
- All 6 routes render via SSR with correct loader data.
- URL parameters parsed correctly for all route shapes.
- Invalid URL parameters return 404 (via `errorToResponse` from 1F-layout).
- `React.lazy()` + `<Suspense>` pattern verified on every page.
- Feature barrel exports only the public surface (no internal component leakage).
- MF expose wrapper renders the feature root in a test host.
- `pnpm typecheck` and `pnpm lint` green.
---
### 2F — SEO + JSON-LD contracts
**Scope:** Build the `buildOnlineBoardSeo()` function for each online board route type. Produce JSON-LD schemas (`Flight` for details, `ItemList` of `Flight` for search results). Consume `SeoHead`, `buildHreflangSet`, and `JsonLdRenderer` from Phase 1's 1F-seo.
**Exports (`src/features/online-board/seo.ts`):**
```ts
import type { SeoHeadProps } from "@/ui/seo/SeoHead";
import type { OnlineBoardParams } from "./url";
import type { ISimpleFlight, FlightDetailsResponse } from "./api";
/**
* Build SeoHead props for any online board route.
* Produces: title, description, canonical, hreflang, OG tags, JSON-LD.
*
* JSON-LD schemas:
* - Flight details → schema.org/Flight
* - Search results → schema.org/ItemList containing Flight items
* - Start page → schema.org/WebPage with SearchAction
*/
export function buildOnlineBoardSeo(
params: OnlineBoardParams,
context: {
cityNames: Record<string, string>; // IATA code → localized city name
locale: string;
canonicalOrigin: string;
flights?: ISimpleFlight[];
flightDetails?: FlightDetailsResponse;
},
): SeoHeadProps;
/**
* Build a JSON-LD Flight object from flight details data.
* Uses schema-dts types for type safety.
*/
export function buildFlightJsonLd(
flight: FlightDetailsResponse,
locale: string,
): import("schema-dts").Flight;
/**
* Build a JSON-LD ItemList of Flight objects from search results.
*/
export function buildFlightSearchResultsJsonLd(
flights: ISimpleFlight[],
params: OnlineBoardParams,
locale: string,
): import("schema-dts").ItemList;
```
**Title/description strategy:** Ported from Angular's translation keys. Pattern:
- Start page: `t("onlineboard.seo.start.title")` / `t("onlineboard.seo.start.description")`
- Flight search: `t("onlineboard.seo.flight.title", { flightNumber })` / `t("onlineboard.seo.flight.description", { flightNumber, date })`
- Departure: `t("onlineboard.seo.departure.title", { cityName })` / `t("onlineboard.seo.departure.description", { cityName, date })`
- Arrival: `t("onlineboard.seo.arrival.title", { cityName })` / etc.
- Route: `t("onlineboard.seo.route.title", { departureCity, arrivalCity })` / etc.
- Details: `t("onlineboard.seo.details.title", { flightNumber, carrier })` / etc.
**JSON-LD schema details:**
Flight details page emits `schema.org/Flight`:
```json
{
"@context": "https://schema.org",
"@type": "Flight",
"flightNumber": "SU 100",
"provider": { "@type": "Airline", "iataCode": "SU", "name": "Aeroflot" },
"departureAirport": { "@type": "Airport", "iataCode": "SVO", "name": "Sheremetyevo", "address": { "@type": "PostalAddress", "addressCountry": "RU" } },
"arrivalAirport": { "@type": "Airport", "iataCode": "JFK", ... },
"departureTime": "2025-01-15T10:00:00+03:00",
"arrivalTime": "2025-01-15T14:30:00-05:00",
"flightDistance": { "@type": "Distance", "value": "9200 km" }
}
```
Search results pages emit `schema.org/ItemList` containing `Flight` items.
**OG images:** Phase 2 enhancement per design spec §6.7 — dynamic per-flight OG images via Satori, served from `routes/og/flight/[params]/image.tsx`, cached `s-maxage=86400`. Falls back to the static default OG image from Phase 1 if Satori generation fails.
**Exit gate for 2F:**
- `buildOnlineBoardSeo` produces valid `SeoHeadProps` for all 6 route types.
- JSON-LD output validates against `schema-dts` types at compile time.
- CI JSON-LD validation job passes (structured data validator against fixture renders).
- `buildHreflangSet` produces correct 9-language + `x-default` set for each route.
- OG tags present and correct for each route type.
- SSR render + `cheerio` parse asserts `<title>`, `<meta name="description">`, `<link rel="canonical">`, `<link rel="alternate" hreflang>`, `<meta property="og:*">`, `<script type="application/ld+json">`.
---
### 2G — Parity harnesses contracts
**Scope:** Build the URL parity harness and SEO parity harness that were deferred from Phase 1 (the "deferred 1J" mentioned in the Phase 1 master plan). These harnesses test against the real Online Board feature, not the synthetic smoke route. Also establish VRT baselines for all online board routes.
**Exports:**
- **`tests/parity/url-parity.test.ts`** — table-driven Vitest test that reads the Phase 0 URL corpus from `tests/fixtures/phase-0/url-corpus-onlineboard.json` and asserts that `parseOnlineBoardUrl` + `buildOnlineBoardUrl` produce byte-exact matches. Merge-blocking CI gate.
- **`tests/parity/seo-parity.test.ts`** — Vitest test that SSR-renders each online board route type, parses the HTML with `cheerio`, and compares the SEO elements against Phase 0 baselines from `tests/fixtures/phase-0/seo-baselines-onlineboard.json`. Checks: `<title>`, `<meta name="description">`, `<link rel="canonical">`, hreflang set, OG tags. JSON-LD presence and schema type checked (content differs from Angular since Angular had no JSON-LD — this is an enhancement, not parity).
- **`tests/vrt/onlineboard/`** — Playwright VRT baseline screenshots for all 6 route types at 3 viewports (375px, 768px, 1440px) x 2 languages (ru, en). Total: 36 baseline images. Threshold: 0.1% pixel diff (configurable). Committed under `tests/fixtures/phase-2/vrt/`.
- **`scripts/parity/run-url-parity.ts`** — standalone script to run URL parity checks outside of CI (for local development). Reads the corpus, runs both parse and build, reports mismatches with diffs.
**Exit gate for 2G:**
- URL parity: 100% of the Phase 0 corpus passes.
- SEO parity: all baseline comparisons pass (with documented exceptions for JSON-LD enhancement).
- VRT baselines committed and CI gate functional (diff threshold violation blocks merge).
- Parity harness documentation in `tests/parity/README.md` explains how to add new test URLs and update baselines.
---
### 2H — Integration tests contracts
**Scope:** Playwright end-to-end tests covering the full Online Board feature in standalone SSR mode. Ported from the 4 passing Cypress scenarios + new tests for SignalR, error cases, and edge cases.
**Test inventory:**
1. **Start page** — navigates to `/{lang}/onlineboard`, verifies search form renders, submits a flight number search, arrives at the correct search results URL.
2. **Departure search** — navigates to a departure URL, verifies flight list renders with correct airport, verifies calendar strip shows available dates, clicks a different date and verifies URL update.
3. **Flight details** — navigates to a details URL, verifies full flight info renders (status, route, codeshares), verifies breadcrumbs.
4. **Language switch** — navigates to `/ru/onlineboard/...`, switches language to `en`, verifies URL updates to `/en/onlineboard/...` with same parameters, verifies content is in English.
5. **SignalR live update** — mocked SignalR hub pushes a `RefreshDate` event, verifies the flight list updates without page reload, verifies connection status badge shows "live".
6. **SignalR disconnect** — mocked SignalR hub disconnects, verifies "offline" badge appears, verifies last-known data remains displayed.
7. **404 handling** — navigates to an invalid onlineboard URL, verifies 404 page renders with correct HTTP status.
8. **API error handling** — mocked API returns 500, verifies error UI renders (not a white screen), verifies "Retry" button triggers re-fetch.
9. **Empty results** — search returns zero flights, verifies empty-state UI renders.
10. **Responsive** — verifies all pages render correctly at 375px, 768px, 1440px without horizontal scroll.
**SignalR mock server:**
```ts
// tests/mocks/signalr-mock-server.ts
export class MockSignalRServer {
constructor(port: number);
start(): Promise<void>;
stop(): Promise<void>;
pushRefreshDate(date: string, departure?: string, arrival?: string): void;
pushFlightUpdate(flightId: string, date: string): void;
getSubscriptions(): { channel: string; args: unknown[] }[];
}
```
The mock server implements the TrackerHub protocol: accepts `SubscribeDate` and `Subscribe` invocations, records them, and allows tests to programmatically push events.
**Exit gate for 2H:**
- All 10 Playwright test scenarios pass.
- SignalR mock server correctly simulates push events and disconnect.
- Tests run in CI in under 5 minutes.
- No flaky tests (3 consecutive CI runs green).
---
## Shared files — cross-sub-plan modification table
| File | Primary owner | Also modified by | What the modifiers add |
|---|---|---|---|
| `src/features/online-board/index.ts` | 1A-1 (empty barrel) | 2E | Populated with all public exports (components, URL functions, API functions, SEO builders, types) |
| `src/mf/expose/OnlineBoard.tsx` | 1A-2 (stub) | 2E | Updated from stub to real: imports `OnlineBoardRoot` from feature barrel, renders with `React.lazy` + `Suspense` |
| `src/ui/flights/types.ts` | 2A | 2B, 2C, 2D, 2E, 2F | Consumed (read-only) by all downstream sub-plans for type imports |
| `src/observability/analytics/adapters/*.ts` | 1G-analytics (stubs) | 2E | Real vendor script loading wired (Yandex.Metrica, CTM, Variocube, Dynatrace) — replaces structured stubs with real implementations when A7 is resolved |
| `package.json` | 1A-1 | 2A | `primereact`, `clsx` added |
| | | 2B | `fast-check` (dev dep) |
| | | 2H | `@playwright/test` (if not already from Phase 1) |
**Modification protocol** — same as Phase 1: downstream sub-plan tasks explicitly reference the primary owner, quote the pre-modification state, show the full post-modification file, and re-run the owner's exit-gate tests.
---
## Spec-coverage matrix
| Spec section | Topic | Sub-plan(s) |
|---|---|---|
| §3.3 | Routing — file-based, precedence | 2E |
| §3.4 | Loaders, Suspense, `React.lazy` | 2E |
| §3.5 | URL parity — ported serializers | 2B |
| §3.6 | Canonical, hreflang, redirects | 2F |
| §4.4 | SignalR wrapper + hook | 2D |
| §4.5 | State management (hooks, reducers) | 2C, 2D |
| §4.6 | Error handling path | 2C, 2E |
| §5.1 | UI adapter boundary | 2A |
| §5.2 | SCSS Modules | 2A |
| §5.3 | PrimeReact theming | 2A |
| §5.4 | Per-component porting workflow | 2A |
| §5.7 | Responsiveness | 2A, 2H |
| §6.5 | `<SeoHead>` usage | 2F |
| §6.6 | JSON-LD schema coverage (Flight, ItemList) | 2F |
| §6.7 | OG images (dynamic per-flight) | 2F |
| §6.8 | Canonical + hreflang correctness | 2F, 2G |
| §8.4 | Testing strategy (URL parity, VRT, Playwright) | 2G, 2H |
| §9.2 | Phase 2 scope + exit gate | All |
---
## Phase 2 global exit gate — checklist
- [ ] **2A:** All UI flight-display components have Vitest tests + VRT baselines; no direct `primereact/*` imports outside `src/ui/`; SCSS compiles clean.
- [ ] **2B:** 100% of Phase 0 URL corpus passes round-trip parity; `fast-check` fuzz tests green; `parseOnlineBoardUrl` returns `null` (never throws) on invalid input.
- [ ] **2C:** API client functions tested for all three endpoints; hooks tested with initial data + refetch; cache behavior verified; zero `any` types.
- [ ] **2D:** SignalR wiring tested: push → re-fetch → updated data; Strict Mode safe; disconnect → offline badge + last-known data; SSR returns idle.
- [ ] **2E:** All 6 routes render SSR with correct loaders; invalid params → 404; `React.lazy` + `Suspense` on every page; feature barrel exports only public surface; MF expose wrapper functional.
- [ ] **2F:** `buildOnlineBoardSeo` produces valid output for all 6 route types; JSON-LD validates against `schema-dts`; hreflang reciprocal across 9 languages; OG tags correct; CI JSON-LD validator passes.
- [ ] **2G:** URL parity 100% against Phase 0 corpus; SEO parity baselines pass; 36 VRT baselines committed and CI gate functional.
- [ ] **2H:** All 10 Playwright scenarios pass; SignalR mock server functional; 3 consecutive CI runs green (no flakes); tests complete in under 5 minutes.
- [ ] **Load test:** Online board routes sustain 150 RPS with p95 latency under 500ms.
- [ ] **WCAG AA:** `@axe-core/playwright` violations block (upgraded from Phase 1 warn-only).
- [ ] **Analytics:** Real vendor scripts (Yandex.Metrica, CTM, Variocube, Dynatrace) emitting in `testing` + `staging`.
- [ ] **Phase 1 regression:** All Phase 1 exit gates still green on `main`.
- [ ] **Security scan:** `osv-scanner` + `npm audit` green after Phase 2 dependency additions.
- [ ] **Bundle size:** Online board feature chunk within budget (budget TBD in 2A based on Angular bundle analysis).
---
## Risks + open questions for Phase 2
1. **PrimeReact vs PrimeNG DOM differences (T2).** Design spec §5.3 identifies Calendar, Autocomplete, DataTable, and Toast as likely failure cases. 2A absorbs this risk in `src/ui/primitives/` wrappers + compensating CSS. Unresolved diffs go to `docs/visual-parity-exceptions.md`.
2. **SignalR TrackerHub protocol undocumented.** The Angular source is the only specification. 2D must reverse-engineer the exact channel names and message shapes. Risk: Angular uses implicit conventions that aren't obvious from source reading alone. Mitigation: run the Angular app against a test hub and capture wire traffic during 2D.
3. **Analytics vendor credentials (A7) still unknown.** If A7 is unresolved when 2E starts, the analytics adapters stay as structured stubs and the "real vendors in testing/staging" exit gate defers to Phase 3. The rest of Phase 2 is not blocked.
4. **Phase 0 URL corpus coverage.** The parity harness is only as good as the corpus. If prod access logs are incomplete (e.g., rare URL shapes not in logs), parity gaps may surface post-cutover. Mitigation: `fast-check` fuzz tests in 2B supplement the corpus with randomized inputs.
5. **Dynamic OG images via Satori.** Satori is a runtime dependency for generating per-flight OG images. If Satori proves unreliable or slow, Phase 2 falls back to the static default OG image and defers dynamic images to a follow-up.
6. **Load test at 150 RPS.** Phase 1's smoke route may not be representative of Online Board's SSR cost (which includes API calls, SignalR connection setup, and JSON-LD generation). Load test infrastructure must mock upstream APIs at realistic latencies.
---
## Cutover plan (from design spec §9.2)
1. Deploy to `staging`; run full test suite + load test + SEO audit.
2. Canary 5% of `/{lang}/onlineboard/*` prod traffic for 24h (request-id hash bucket behind proxy); rest stays on Angular.
3. Monitor: error rate, p95 latency, `flights.react.error`, `flights.api.error`, SignalR health, Web Vitals, Search Console crawl errors.
4. If clean: 25% → 50% → 100% over 72h, always reversible.
5. Hold at 100% for 1 week. Then retire (not delete) Angular online-board code.
**Cutover is NOT part of the sub-plans** — it executes after 2H passes and is tracked as an operational procedure, not a development task.
---
## How to write each sub-plan
When the user is ready to execute a sub-plan, re-invoke `superpowers:writing-plans` with a specific prompt like:
> "Write sub-plan 2A (UI adapter layer) from `docs/superpowers/plans/2026-04-14-phase-2-online-board-master.md`. Target file: `docs/superpowers/plans/2026-04-14-phase-2a-ui-flights.md`. Follow the contracts defined in the master plan §2A exactly; reference the design spec §5 as source material and the Angular component inventory from Phase 0."
The sub-plan writer must:
1. Read this master plan in full for the dependency + contract context.
2. Read the Phase 1 master plan for the contracts Phase 2 consumes.
3. Read the relevant design spec sections.
4. Read any upstream sub-plans that have already been written (their exit gates lock in file/API shapes).
5. Produce a fully TDD-granular plan at the shape of the Phase 1 sub-plan format.
6. Match the contracts in this master plan byte-for-byte on type signatures. Any contract change requires updating this master plan first.
---
## Self-review
**Spec coverage.** Every Phase 2-relevant design-spec section (§3.3-3.6, §4.4-4.6, §5, §6.5-6.8, §8.4, §9.2) maps to at least one sub-plan in the spec-coverage matrix.
**Placeholder scan.** No `TBD` / `TODO` / `FIXME` outside of the "TBW" markers on sub-plan filenames and the bundle-size budget note (which is deliberately deferred to 2A since it depends on Angular bundle analysis).
**Internal consistency.** Cross-checked: `ISimpleFlight` + `IParsedFlightId` + `FlightRequestType` defined in 2A → consumed by 2B, 2C, 2D, 2E, 2F; `OnlineBoardParams` defined in 2B → consumed by 2C, 2E, 2F; `BoardResponse` + `FlightDetailsResponse` defined in 2C → consumed by 2D, 2E, 2F; `SeoHeadProps` from 1F-seo → consumed by 2F; `useLiveFlights` from 1E → consumed by 2D; `ApiClient` from 1D → consumed by 2C; `SignalRConnection` from 1E → consumed by 2D.
**Phase 1 contract consumption.** Every Phase 1 contract used by Phase 2 is listed in the prerequisite section. No Phase 2 sub-plan creates functionality already provided by Phase 1 — it only wires and extends.
**Dependency graph acyclicity.** The graph is a strict DAG: 2A → 2C → 2D → 2E → 2F → 2G → 2H, with 2B as an independent node feeding into 2E. No cycles.
---
## Next step
- **If you approve this master plan:** say so, and I'll write sub-plan **2A** (UI adapter layer) in the next session.
- **If you want changes:** tell me, I revise.
@@ -1,112 +0,0 @@
# Phase 2A -- UI Adapter Layer
> Sub-plan of Phase 2 (Online Board). Implements the flight-display component library at `src/ui/flights/` and supporting utilities.
**Depends on:** Phase 1 (foundation complete).
**Consumed by:** 2C (API hooks use types), 2E (pages compose components).
---
## Tasks
### Task 1: Data model types (`src/features/online-board/types.ts`)
Port the key Angular interfaces to minimal React-friendly TypeScript types. Source analysis:
- Angular `ISimpleFlight` = `IDirectFlight | IMultiLegFlight` (`ClientApp/src/typings/flight/flight.ts`)
- `IFlightLeg` with stations, times, status (`ClientApp/src/typings/flight/flight-leg.ts`)
- `IFlightLegStation` with `IAirportInfo` (`ClientApp/src/typings/flight/flight-station.ts`)
- `ITimesSet` with dayChange, local, utc, tzOffset (`ClientApp/src/typings/times.ts`)
- `FlightRequestType` enum mapping from Angular `RequestMode` (`ClientApp/src/typings/enums.ts`)
- `IParsedFlightId` (`ClientApp/src/typings/flight/flight-id.ts`)
- `IBoardResponse`, `IDaysResponse` (`ClientApp/src/typings/responses.ts`)
Flatten into idiomatic TS -- no class hierarchies, no Angular deps.
**Test:** Type-level tests (compile-time satisfaction checks) in `src/features/online-board/types.test.ts`.
### Task 2: Datetime utility functions (`src/shared/utils/datetime/`)
Pure functions ported from Angular pipes:
- `formatDuration(minutes: number): string` -- from `DurationPipe` logic
- `formatTime(date: string | Date): string` -- "HH:mm" format
- `formatDate(date: string | Date, locale?: string): string` -- localized date string
- `isDayChange(scheduledDate: string | Date, actualDate: string | Date): number` -- day offset
**Test:** `src/shared/utils/datetime/datetime.test.ts` -- TDD, write tests first.
### Task 3: Airport/city dictionary hook (`src/shared/hooks/useDictionaries.ts`)
- `useCityName(code: string): string` -- returns city name for IATA code
- Phase 2 stub: returns the code itself (passthrough) with TODO for real API
- Angular source: `DictionariesService` fetches from `networkService.getDictionary('cities')` -- API endpoint TBD from customer
**Test:** `src/shared/hooks/useDictionaries.test.ts` -- verify passthrough behavior.
### Task 4: `StationDisplay.tsx` component
Renders airport IATA code + city name. Uses `useCityName` hook.
Props: `{ airportCode: string; airportName?: string; cityName?: string }`.
No test (UI component -- visual testing in 2G).
### Task 5: `TimeGroup.tsx` component
Displays scheduled + actual times with day-change indicator.
Props: `{ scheduled: string; actual?: string; dayChange?: number; label?: string }`.
Uses `formatTime` utility.
### Task 6: `FlightStatus.tsx` component
Status badge with semantic styling.
Props: `{ status: FlightStatus }`.
Maps status to display label + CSS class.
### Task 7: `DurationDisplay.tsx` component
Flight duration display.
Props: `{ minutes: number }`.
Uses `formatDuration` utility.
### Task 8: `FlightCard.tsx` component
Composes Station + TimeGroup + Status + Duration into a flight row.
Props: `{ flight: ISimpleFlight }`.
### Task 9: `FlightListSkeleton.tsx` component
Loading placeholder for flight list. Renders N placeholder rows with CSS animation.
Props: `{ count?: number }`.
### Task 10: `FlightList.tsx` component
List of FlightCards with loading state.
Props: `{ flights: ISimpleFlight[]; loading?: boolean }`.
Uses `FlightListSkeleton` when loading.
### Task 11: Update barrel exports
- `src/ui/flights/index.ts` -- export all components and types
- `src/ui/index.ts` -- re-export from `./flights`
---
## Exit criteria
- `pnpm typecheck` green
- `pnpm lint` green
- `pnpm test` green with datetime utility tests + type satisfaction tests + dictionary hook test passing
- No Angular dependencies in any new file
- All components are pure functional React components with typed props
@@ -1,152 +0,0 @@
# Phase 2B — URL Serializer/Parser
> **Parent:** `2026-04-14-phase-2-online-board-master.md` (sub-plan 2B)
> **Status:** Ready to execute
## Goal
TDD port of the Angular `OnlineBoardUrlBuilderService` / `OnlineBoardUrlParserService` into pure TypeScript functions with zero Angular dependencies. Byte-exact URL parity with Angular.
## Deliverable
`src/features/online-board/url.ts` — pure functions, no side effects, no Date objects.
## URL Format Reference (from Angular analysis)
| Type | Pattern | Example |
|---|---|---|
| Start | `/onlineboard` or `/onlineboard/` | `/onlineboard` |
| Flight | `/onlineboard/flight/{carrier}{flightNumber}{suffix}-{yyyyMMdd}` | `/onlineboard/flight/SU0100D-20250115` |
| Departure | `/onlineboard/departure/{station}-{yyyyMMdd}[-{HHmmHHmm}]` | `/onlineboard/departure/SVO-20250115-08001800` |
| Arrival | `/onlineboard/arrival/{station}-{yyyyMMdd}[-{HHmmHHmm}]` | `/onlineboard/arrival/LED-20250115` |
| Route | `/onlineboard/route/{dep}-{arr}-{yyyyMMdd}[-{HHmmHHmm}]` | `/onlineboard/route/SVO-LED-20250115` |
| Details | `/onlineboard/{carrier}{flightNumber}{suffix}-{yyyyMMdd}` | `/onlineboard/SU0100-20250115` |
### Key rules
- **Date format:** `yyyyMMdd` (8 digits, no separators)
- **Time range:** `{HHmm}{HHmm}` (8 digits continuous, from-to)
- **Flight number:** zero-padded to 4 digits in the URL (e.g., `100` becomes `0100`), total flightNumber+suffix = last 5 chars
- **Carrier code:** 2 characters (IATA), or 3 characters if 3rd char is a letter (rare 3-char carriers)
- **Suffix:** optional single trailing letter on a flight identifier (e.g., `D` in `SU0100D`)
- **Station codes:** 3-letter IATA airport codes
## Exported API
```ts
type OnlineBoardParams =
| { type: "start" }
| { type: "flight"; carrier: string; flightNumber: string; suffix?: string; date: string }
| { type: "departure"; station: string; date: string; timeFrom?: string; timeTo?: string }
| { type: "arrival"; station: string; date: string; timeFrom?: string; timeTo?: string }
| { type: "route"; departure: string; arrival: string; date: string; timeFrom?: string; timeTo?: string }
| { type: "details"; carrier: string; flightNumber: string; suffix?: string; date: string };
function parseOnlineBoardUrl(path: string): OnlineBoardParams | null;
function buildOnlineBoardUrl(params: OnlineBoardParams): string;
// Lower-level helpers (also exported for 2C/2E consumption)
function parseFlightUrlParams(raw: string): IParsedFlightId | null;
function buildFlightUrlParams(id: IParsedFlightId): string;
function parseStationUrlParams(raw: string): { station: string; date: string; timeFrom?: string; timeTo?: string } | null;
function parseRouteUrlParams(raw: string): { departure: string; arrival: string; date: string; timeFrom?: string; timeTo?: string } | null;
```
## Tasks
### Task 1: Write failing tests for `parseFlightUrlParams`
**File:** `src/features/online-board/url.test.ts`
Test cases:
- `SU0100-20250115` -> `{ carrier: "SU", flightNumber: "0100", date: "20250115" }`
- `SU0100D-20250115` -> `{ carrier: "SU", flightNumber: "0100", suffix: "D", date: "20250115" }`
- `SU100-20250115` -> `{ carrier: "SU", flightNumber: "100", date: "20250115" }` (3-digit)
- `SU1234-20250115` -> `{ carrier: "SU", flightNumber: "1234", date: "20250115" }` (4-digit)
- Empty string -> `null`
- Missing date part -> `null`
### Task 2: Implement `parseFlightUrlParams` to pass tests
### Task 3: Write failing tests for `buildFlightUrlParams`
Test cases:
- `{ carrier: "SU", flightNumber: "100", date: "20250115" }` -> `SU0100-20250115` (padded)
- `{ carrier: "SU", flightNumber: "0100", suffix: "D", date: "20250115" }` -> `SU0100D-20250115`
- `{ carrier: "SU", flightNumber: "1234", date: "20250115" }` -> `SU1234-20250115`
### Task 4: Implement `buildFlightUrlParams` to pass tests
### Task 5: Write failing tests for `parseStationUrlParams`
Test cases:
- `SVO-20250115` -> `{ station: "SVO", date: "20250115" }`
- `SVO-20250115-08001800` -> `{ station: "SVO", date: "20250115", timeFrom: "0800", timeTo: "1800" }`
- Empty string -> `null`
### Task 6: Implement `parseStationUrlParams` to pass tests
### Task 7: Write failing tests for `parseRouteUrlParams`
Test cases:
- `SVO-LED-20250115` -> `{ departure: "SVO", arrival: "LED", date: "20250115" }`
- `SVO-LED-20250115-08001800` -> `{ departure: "SVO", arrival: "LED", date: "20250115", timeFrom: "0800", timeTo: "1800" }`
- Empty string -> `null`
### Task 8: Implement `parseRouteUrlParams` to pass tests
### Task 9: Write failing tests for `parseOnlineBoardUrl`
Test cases:
- `/onlineboard` -> `{ type: "start" }`
- `/onlineboard/` -> `{ type: "start" }`
- `/onlineboard/flight/SU0100-20250115` -> `{ type: "flight", carrier: "SU", flightNumber: "0100", date: "20250115" }`
- `/onlineboard/departure/SVO-20250115` -> `{ type: "departure", station: "SVO", date: "20250115" }`
- `/onlineboard/arrival/LED-20250115-08001800` -> `{ type: "arrival", station: "LED", date: "20250115", timeFrom: "0800", timeTo: "1800" }`
- `/onlineboard/route/SVO-LED-20250115` -> `{ type: "route", departure: "SVO", arrival: "LED", date: "20250115" }`
- `/onlineboard/SU0100-20250115` -> `{ type: "details", carrier: "SU", flightNumber: "0100", date: "20250115" }`
- `/some/other/path` -> `null`
- Empty string -> `null`
### Task 10: Implement `parseOnlineBoardUrl` to pass tests
### Task 11: Write failing tests for `buildOnlineBoardUrl`
Test cases:
- `{ type: "start" }` -> `onlineboard`
- `{ type: "flight", carrier: "SU", flightNumber: "100", date: "20250115" }` -> `onlineboard/flight/SU0100-20250115`
- `{ type: "departure", station: "SVO", date: "20250115" }` -> `onlineboard/departure/SVO-20250115`
- `{ type: "departure", station: "SVO", date: "20250115", timeFrom: "0800", timeTo: "1800" }` -> `onlineboard/departure/SVO-20250115-08001800`
- `{ type: "arrival", station: "LED", date: "20250115" }` -> `onlineboard/arrival/LED-20250115`
- `{ type: "route", departure: "SVO", arrival: "LED", date: "20250115" }` -> `onlineboard/route/SVO-LED-20250115`
- `{ type: "details", carrier: "SU", flightNumber: "0100", date: "20250115" }` -> `onlineboard/SU0100-20250115`
### Task 12: Implement `buildOnlineBoardUrl` to pass tests
### Task 13: Roundtrip tests
For every URL type: `buildOnlineBoardUrl(parseOnlineBoardUrl(url)) === url` (after normalization).
### Task 14: Edge case tests
- Suffixed flights roundtrip: `SU0100D-20250115`
- 3-digit flight numbers: build pads to 4, parse handles both
- Time range only partially specified (only timeFrom, no timeTo) -> no time range in URL
- Invalid date format -> null
- Unknown route prefix -> null
### Task 15: Export from barrel
Update `src/features/online-board/index.ts` to re-export the public API.
### Task 16: Verification
Run `pnpm typecheck && pnpm lint && pnpm test`.
## Exit criteria
- All tests pass
- `parseOnlineBoardUrl` returns null for invalid inputs, never throws
- Zero `any` types
- Pure functions only — no side effects, no imports from Angular
- Dates are strings (`yyyyMMdd`), not Date objects
@@ -1,96 +0,0 @@
# Phase 2C — API Client Functions + React Hooks
> **Parent plan:** `2026-04-14-phase-2-online-board-master.md` (sub-plan 2C)
## Goal
Wrap the three Online Board REST endpoints in typed, pure API functions and thin React hooks. API functions are dependency-injected with `ApiClient`; hooks use `useApiClient()` from context.
## Prerequisites
- Phase 1 `ApiClient` (`src/shared/api/client.ts`) and `useApiClient` (`src/shared/api/provider.tsx`) exist.
- Phase 2A types (`src/features/online-board/types.ts`) exist: `ISimpleFlight`, `IBoardResponse`, `IDaysResponse`, `IParsedFlightId`, `FlightRequestType`.
- Phase 2B URL serializer (`src/features/online-board/url.ts`) exists.
## Deliverables
| File | Purpose |
|---|---|
| `src/features/online-board/api.ts` | Pure API functions: `searchFlights`, `getFlightDetails`, `getCalendarDays` |
| `src/features/online-board/api.test.ts` | TDD tests for API functions (mock fetch, verify URL construction + response mapping) |
| `src/features/online-board/hooks/useOnlineBoard.ts` | React hook for search pages |
| `src/features/online-board/hooks/useFlightDetails.ts` | React hook for details page |
| `src/features/online-board/hooks/useCalendarDays.ts` | React hook for calendar availability |
| `src/features/online-board/index.ts` | Updated barrel exports |
## Tasks
### T1: Define API function param types and write `api.ts`
**File:** `src/features/online-board/api.ts`
Three pure functions, each taking `ApiClient` as first param:
1. `searchFlights(client, params)` — builds `GET /{locale}/board?...` with optional query params: `flightNumber`, `dateFrom`, `dateTo`, `departure`, `arrival`, `timeFrom`, `timeTo`. Returns `IBoardResponse`.
2. `getFlightDetails(client, params)` — builds `GET /{locale}/onlineboard/details?flights={carrier}{number}&dates={date}`. Returns `IBoardResponse`.
3. `getCalendarDays(client, params)` — builds `GET /v1/days/{date}/31/{searchType}/{searchParams}/board/`. Returns parsed `string[]` from `IDaysResponse.days`.
**Key design decisions:**
- Functions are PURE — no context, no hooks, no side effects.
- Use `ApiClient.get<T>(path, query)` for query-param endpoints.
- For calendar endpoint, build the path string directly (it's path-based, not query-based).
- Map `IDaysResponse.days` (a single string) to `string[]` by splitting on comma.
### T2: Write TDD tests for `api.ts`
**File:** `src/features/online-board/api.test.ts`
Test cases:
- `searchFlights`: correct URL + query params for flight-number search, route search, departure-only, arrival-only; response deserialization
- `getFlightDetails`: correct URL + query params; response deserialization
- `getCalendarDays`: correct path construction for flight type, route type, departure type, arrival type; response mapping from comma-separated string to array
- Error cases: 404 throws `ApiHttpError`, timeout throws `ApiTimeoutError`
Mock strategy: create `ApiClient` with a mock `fetchImpl` that captures the request URL and returns canned responses.
### T3: Write `useOnlineBoard` hook
**File:** `src/features/online-board/hooks/useOnlineBoard.ts`
- Gets `ApiClient` via `useApiClient()`
- `useState` for flights, loading, error
- `useEffect` calls `searchFlights` on param change
- Returns `{ flights, loading, error, refresh }`
### T4: Write `useFlightDetails` hook
**File:** `src/features/online-board/hooks/useFlightDetails.ts`
- Gets `ApiClient` via `useApiClient()`
- `useState` for flight, loading, error
- `useEffect` calls `getFlightDetails` on param change
- Returns `{ flight, loading, error }`
### T5: Write `useCalendarDays` hook
**File:** `src/features/online-board/hooks/useCalendarDays.ts`
- Gets `ApiClient` via `useApiClient()`
- `useState` for days, loading
- `useEffect` calls `getCalendarDays` on param change
- Returns `{ days, loading }`
### T6: Update barrel exports in `index.ts`
Add API functions and hooks to the public barrel.
### T7: Verify — `pnpm typecheck && pnpm lint && pnpm test`
## Exit gate
- All API function tests pass (URL construction, response mapping, error handling).
- `pnpm typecheck` green — zero `any` types.
- `pnpm lint` green.
- Hooks compile and export correctly.
@@ -1,84 +0,0 @@
# Phase 2D — SignalR Wiring
> **Parent:** `2026-04-14-phase-2-online-board-master.md` § 2D
>
> **Goal:** Wire the generic `useLiveFlights` hook (1E) to TrackerHub channels for the Online Board's search and details pages. Two thin composition hooks, each tested.
---
## Prerequisites
| Artifact | Source |
|---|---|
| `useLiveFlights<TParams, TData>` | `src/shared/hooks/useLiveFlights.ts` (1E) |
| `SignalRConnection`, `ConnectionStatus` | `src/shared/signalr/connection.ts` (1E) |
| `useOnlineBoard` (refresh callback) | `src/features/online-board/hooks/useOnlineBoard.ts` (2C) |
| `useFlightDetails` | `src/features/online-board/hooks/useFlightDetails.ts` (2C) |
| `ISimpleFlight`, `IFlightId` | `src/features/online-board/types.ts` (2A) |
| `getEnv().SIGNALR_HUB_URL` | `src/env/index.ts` (1A) |
---
## Tasks
### T1 — Create `useLiveBoardSearch` hook
**File:** `src/features/online-board/hooks/useLiveBoardSearch.ts`
- Accepts `{ date: string; departure?: string; arrival?: string }` params and `initialFlights: ISimpleFlight[]`.
- Composes `useLiveFlights` with config:
- `hubUrl` from `getEnv().SIGNALR_HUB_URL`
- `channelKey` builds `board:${date}:${departure ?? ""}:${arrival ?? ""}`
- Returns `{ flights: ISimpleFlight[]; connectionStatus: ConnectionStatus }`.
- Client-only: `useLiveFlights` already handles SSR guard.
### T2 — Create `useLiveFlightDetails` hook
**File:** `src/features/online-board/hooks/useLiveFlightDetails.ts`
- Accepts `{ carrier: string; flightNumber: string; suffix?: string; date: string }` (matches `IParsedFlightId`) and `initialFlight: ISimpleFlight | null`.
- Composes `useLiveFlights` with config:
- `hubUrl` from `getEnv().SIGNALR_HUB_URL`
- `channelKey` builds `flight:${carrier}${flightNumber}${suffix ?? ""}@${date}`
- Returns `{ flight: ISimpleFlight | null; connectionStatus: ConnectionStatus }`.
### T3 — Write tests for `useLiveBoardSearch`
**File:** `src/features/online-board/hooks/useLiveBoardSearch.test.ts`
- **Channel key construction:** date-only, date+departure, date+departure+arrival all produce correct keys.
- **Data passthrough:** initial data flows through from `useLiveFlights`.
- **Live update:** simulated SignalR message replaces flight data.
- **SSR:** returns initial data with idle status when `window` is undefined.
### T4 — Write tests for `useLiveFlightDetails`
**File:** `src/features/online-board/hooks/useLiveFlightDetails.test.ts`
- **Channel key construction:** with and without suffix.
- **Data passthrough:** initial flight (or null) flows through.
- **Live update:** simulated message replaces single flight.
- **SSR:** idle status, no SignalR import.
### T5 — Export from barrel
**File:** `src/features/online-board/index.ts`
- Add exports for both hooks and their result types.
### T6 — Verification
- `pnpm typecheck && pnpm lint && pnpm test`
---
## Exit gates
| Gate | Verification |
|---|---|
| Channel keys correct | Unit tests for key construction |
| Live updates flow | Mock SignalR push updates state |
| SSR safe | Returns initial data without touching SignalR |
| No regressions | All existing tests still pass |
| Types clean | `pnpm typecheck` passes |
| Lint clean | `pnpm lint` passes |
@@ -1,100 +0,0 @@
# Phase 2E — Routes + Pages
> **Parent plan:** `2026-04-14-phase-2-online-board-master.md` section 2E
>
> **Depends on:** 2A (UI adapter), 2B (URL serializer), 2C (API hooks), 2D (SignalR wiring)
>
> **Scope:** 6 route pages under `src/routes/[lang]/onlineboard/`, 3 feature-specific components in `src/features/online-board/components/`, barrel export updates, MF expose update.
---
## Task inventory
### T1 — Shared `OnlineBoardSearchPage` component
**File:** `src/features/online-board/components/OnlineBoardSearchPage.tsx`
Shared component composed by all 4 search pages (flight, departure, arrival, route). Accepts parsed URL params, converts to `SearchFlightsParams`, wires `useOnlineBoard` + `useLiveBoardSearch` + `useCalendarDays`, renders `FlightList` + `ConnectionStatusBadge` (inline). Provides `useNavigate` callbacks for date changes and flight detail links.
**Test:** `src/features/online-board/components/OnlineBoardSearchPage.test.tsx` — renders with mock provider, verifies `FlightList` presence and navigation wiring.
### T2 — `OnlineBoardStartPage` component
**File:** `src/features/online-board/components/OnlineBoardStartPage.tsx`
Start page with plain HTML search form: radio buttons for search mode (flight/departure/arrival/route), text inputs for flight number / airport codes, date input. Submits via `useNavigate` to the correct search URL using `buildOnlineBoardUrl`.
**Test:** `src/features/online-board/components/OnlineBoardStartPage.test.tsx`
### T3 — `OnlineBoardDetailsPage` component
**File:** `src/features/online-board/components/OnlineBoardDetailsPage.tsx`
Flight details page. Accepts `IParsedFlightId`, wires `useFlightDetails` + `useLiveFlightDetails`, renders flight info (legs, stations, times) using FlightCard + inline detail sections.
**Test:** `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx`
### T4 — Start page route
**File:** `src/routes/[lang]/onlineboard/page.tsx`
Renders `OnlineBoardStartPage`. No API calls. Wraps in `SeoHead` with basic title.
### T5 — Flight search route
**File:** `src/routes/[lang]/onlineboard/flight/[params]/page.tsx`
Parses URL via `parseFlightUrlParams`, renders `OnlineBoardSearchPage` with `type: "flight"`.
### T6 — Departure search route
**File:** `src/routes/[lang]/onlineboard/departure/[params]/page.tsx`
Parses URL via `parseStationUrlParams`, renders `OnlineBoardSearchPage` with `type: "departure"`.
### T7 — Arrival search route
**File:** `src/routes/[lang]/onlineboard/arrival/[params]/page.tsx`
Parses URL via `parseStationUrlParams`, renders `OnlineBoardSearchPage` with `type: "arrival"`.
### T8 — Route search route
**File:** `src/routes/[lang]/onlineboard/route/[params]/page.tsx`
Parses URL via `parseRouteUrlParams`, renders `OnlineBoardSearchPage` with `type: "route"`.
### T9 — Flight details route
**File:** `src/routes/[lang]/onlineboard/[params]/page.tsx`
Parses URL via `parseFlightUrlParams`, renders `OnlineBoardDetailsPage`.
### T10 — Update feature barrel
**File:** `src/features/online-board/index.ts`
Add exports for `OnlineBoardStartPage`, `OnlineBoardSearchPage`, `OnlineBoardDetailsPage`.
### T11 — Update MF expose
**File:** `src/mf/expose/OnlineBoard.tsx`
Replace stub with `React.lazy` + `Suspense` loading `OnlineBoardStartPage` from the feature barrel.
### T12 — Verification
Run `pnpm typecheck && pnpm lint && pnpm test && pnpm build:standalone`.
---
## Execution order
T1 → T2 → T3 → T4,T5,T6,T7,T8,T9 (parallel) → T10 → T11 → T12
## Commit plan
1. Plan file (this document)
2. Feature components (T1 + T2 + T3 + tests)
3. Route pages (T4-T9)
4. Barrel + MF expose updates (T10 + T11) + verification
@@ -1,86 +0,0 @@
# Phase 2F — SEO + JSON-LD for Online Board
> **Parent plan:** `2026-04-14-phase-2-online-board-master.md` (sub-plan 2F)
> **Depends on:** 2E (route pages), 1F-seo (`SeoHead`, `buildHreflangSet`, `JsonLdRenderer`), 1C (i18n)
## Goal
Wire SEO metadata (title, description, canonical, hreflang, OG, Twitter Card) and JSON-LD structured data (`Flight`, `ItemList`) into all 6 Online Board route pages. SEO builders are pure functions; JSON-LD builders produce `schema-dts` typed objects.
## Deliverables
1. `src/features/online-board/seo.ts` — 6 builder functions returning `SeoHeadProps`
2. `src/features/online-board/json-ld.ts` — 2 JSON-LD builder functions
3. Updated route pages wiring `<SeoHead>` and `<JsonLdRenderer>`
4. Populated EN locale SEO keys (RU already has values)
## Tasks
### T1 — Populate EN locale SEO keys
The EN locale file has empty SEO.BOARD keys. Add English translations matching the Russian pattern.
- File: `src/i18n/locales/en/common.json`
- Keys: `SEO.BOARD.MAIN`, `SEO.BOARD.FLIGHT-SEARCH`, `SEO.BOARD.DEPARTURE-SEARCH`, `SEO.BOARD.ARRIVAL-SEARCH`, `SEO.BOARD.ROUTE-SEARCH`, `SEO.BOARD.FLIGHT-DETAILS`
### T2 — TDD: SEO builder functions (`seo.ts`)
Write tests first in `src/features/online-board/seo.test.ts`, then implement in `src/features/online-board/seo.ts`.
**Functions:**
- `buildOnlineBoardStartSeo(locale, canonicalOrigin)` — start page SEO
- `buildFlightSearchSeo(params, locale, canonicalOrigin, cityNames?)` — flight number search
- `buildDepartureSearchSeo(params, locale, canonicalOrigin, cityNames?)` — departure search
- `buildArrivalSearchSeo(params, locale, canonicalOrigin, cityNames?)` — arrival search
- `buildRouteSearchSeo(params, locale, canonicalOrigin, cityNames?)` — route search
- `buildFlightDetailsSeo(flight, locale, canonicalOrigin)` — flight details page
Each returns `SeoHeadProps` with title, description, canonical, hreflang, og, twitter.
**Test strategy:** Unit tests with mocked i18n `t()` function verifying:
- Correct translation keys passed to `t()`
- Correct interpolation variables
- Canonical URL structure
- Hreflang set included
- OG tags populated
- Twitter card set
### T3 — TDD: JSON-LD builder functions (`json-ld.ts`)
Write tests first in `src/features/online-board/json-ld.test.ts`, then implement in `src/features/online-board/json-ld.ts`.
**Functions:**
- `buildFlightJsonLd(flight: ISimpleFlight)` — returns `schema-dts` `Flight` thing
- `buildFlightListJsonLd(flights: ISimpleFlight[], searchDescription: string)` — returns `ItemList`
**Test strategy:** Unit tests verifying:
- Correct `@type` set
- Flight number, departure/arrival airports mapped
- Departure/arrival times mapped
- `ItemList` wraps flights as `ListItem` elements
- Type-checks against `schema-dts` types
### T4 — Wire SEO into route pages
Update the 6 route page files to import and use `<SeoHead>` with appropriate builder + `<JsonLdRenderer>`. No TDD for this step (visual verification).
- `src/routes/[lang]/onlineboard/page.tsx` — start page
- `src/routes/[lang]/onlineboard/flight/[params]/page.tsx` — flight search
- `src/routes/[lang]/onlineboard/departure/[params]/page.tsx` — departure search
- `src/routes/[lang]/onlineboard/arrival/[params]/page.tsx` — arrival search
- `src/routes/[lang]/onlineboard/route/[params]/page.tsx` — route search
- `src/routes/[lang]/onlineboard/[params]/page.tsx` — flight details
### T5 — Export from barrel
Add SEO + JSON-LD exports to `src/features/online-board/index.ts`.
### T6 — Verify
Run `pnpm typecheck && pnpm lint && pnpm test && pnpm build:standalone`.
## Commit plan
1. **Commit 1:** EN locale SEO keys + SEO builder tests + implementation (`seo.ts`)
2. **Commit 2:** JSON-LD builder tests + implementation (`json-ld.ts`)
3. **Commit 3:** Wire SEO into route pages + barrel exports + verify
@@ -1,81 +0,0 @@
# Phase 2G — Parity Harnesses
> **Parent:** `2026-04-14-phase-2-online-board-master.md` (sub-plan 2G)
> **Depends on:** 2B (URL serializer), 2F (SEO builders)
## Goal
Build generic, reusable parity harnesses for URL serialization and SEO output.
Register the Online Board feature against both harnesses. Phase 3+ features
will register their own serializers into the same framework.
## Deliverables
1. **URL parity harness** (`tests/parity/url/harness.ts`) — generic framework
for table-driven + `fast-check` property-based URL roundtrip tests.
2. **URL parity test** (`tests/parity/url/onlineboard.test.ts`) — Online Board
registered against the harness, testing against fixture corpus.
3. **URL fixture corpus** (`tests/fixtures/phase-2/url-corpus/onlineboard.json`)
— ~20 representative URLs covering all 6 route types.
4. **SEO parity harness** (`tests/parity/seo/harness.ts`) — generic framework
for testing SEO builder output shape/completeness.
5. **SEO parity test** (`tests/parity/seo/onlineboard.test.ts`) — Online Board
SEO builders registered against the harness.
6. **`fast-check`** installed as a dev dependency.
## Task list
### T1 — Install `fast-check`
- `pnpm add -D fast-check`
- Verify: `pnpm typecheck`
### T2 — Create URL fixture corpus
- File: `tests/fixtures/phase-2/url-corpus/onlineboard.json`
- ~20 entries covering: start, flight (with/without suffix, 3-char carrier),
departure (with/without time range), arrival (with/without time range),
route (with/without time range), details (with/without suffix).
- Each entry: `{ url: string, expected: OnlineBoardParams }`.
### T3 — URL parity harness
- File: `tests/parity/url/harness.ts`
- Exports `UrlParityConfig<T>` interface and `defineUrlParityTests<T>()`.
- Table-driven tests: reads fixture file, for each entry asserts
`parse(url) === expected` and `build(expected) === url`.
- Fuzz tests: uses `fast-check` arbitrary to generate random params,
asserts `parse(build(params)) deep-equals params` (roundtrip property).
### T4 — Register Online Board URL parity
- File: `tests/parity/url/onlineboard.test.ts`
- Imports harness + URL serializer + fixtures.
- Defines `fc.Arbitrary<OnlineBoardParams>` covering all 6 discriminants.
- Calls `defineUrlParityTests(config)`.
### T5 — SEO parity harness
- File: `tests/parity/seo/harness.ts`
- Exports `SeoParityConfig` interface and `defineSeoParityTests()`.
- Tests: for each registered SEO builder call, asserts output has all required
fields (title, description, canonical, hreflang with 10 entries, OG tags).
### T6 — Register Online Board SEO parity
- File: `tests/parity/seo/onlineboard.test.ts`
- Registers all 6 Online Board SEO builders.
- Asserts shape correctness for each route type x 2 languages (ru, en).
### T7 — Verification
- `pnpm typecheck && pnpm lint && pnpm test`
- All new tests pass. All existing tests still pass.
## Exit gates
- URL parity: 100% of fixture corpus passes roundtrip.
- `fast-check` fuzz finds no roundtrip asymmetry (100 iterations default).
- SEO parity: all 6 route types produce complete SeoHeadProps.
- `pnpm typecheck`, `pnpm lint`, `pnpm test` green.
- No `any` types in harness code.
@@ -1,86 +0,0 @@
# Phase 2H — Integration Tests
**Date:** 2026-04-15
**Parent:** `2026-04-14-phase-2-online-board-master.md` section 2H
**Scope:** Component-level integration tests for the Online Board feature
## Overview
Integration tests that verify the Online Board feature works end-to-end at the component level. Since CI cannot run a full Modern.js dev server + real SignalR hub, these are **vitest + React Testing Library** tests with mocked API and SignalR layers.
## Deviation from master plan
The master plan specifies Playwright E2E tests. This sub-plan implements **component-level integration tests** instead — they run in vitest/jsdom, mock the API and SignalR layers, and verify component composition, data flow, URL routing, error handling, and SEO output. This is faster, more reliable in CI, and catches the same integration regressions without requiring a running server.
## Test inventory
### 1. `tests/integration/online-board/start-page.test.tsx`
- Renders start page, verifies search form with all 4 mode tabs
- Verifies correct form fields appear per search type
- Verifies form submission builds correct URL
### 2. `tests/integration/online-board/flight-search.test.tsx`
- Renders search page with mocked API returning flight data
- Verifies FlightList renders with correct flights
- Verifies connection status badge renders
- Verifies calendar strip renders with available days
### 3. `tests/integration/online-board/flight-details.test.tsx`
- Renders details page with mocked API returning flight details
- Verifies flight info renders (number, status, legs, operating carrier)
- Verifies SeoHead produces expected title/canonical
- Verifies error state when API fails
### 4. `tests/integration/online-board/url-roundtrip.test.tsx`
- Build URL from params -> parse URL -> verify params match
- Verify all 4 search types + details type round-trip correctly
- Verify URL params produce correct API call parameters (via toSearchParams)
### 5. `tests/integration/online-board/error-handling.test.tsx`
- Renders search page with mocked API error (ApiHttpError 404, 500)
- Renders search page with mocked timeout error
- Verifies error UI renders with retry button
- Verifies retry triggers re-fetch
### 6. `tests/integration/online-board/seo-integration.test.tsx`
- Renders details page via renderToString, verifies title/description/canonical in HTML
- Verifies JSON-LD script block present in rendered HTML
- Verifies OG meta tags in rendered HTML
- Verifies hreflang links in rendered HTML
## Mocking strategy
- **ApiClient**: Mocked via `vi.mock("@/shared/api/provider.js")``useApiClient()` returns a mock `ApiClient` whose `.get()` resolves with fixture data.
- **SignalR hooks**: Mocked via `vi.mock("../hooks/useLiveBoardSearch.js")` and `vi.mock("../hooks/useLiveFlightDetails.js")` — return static data with configurable connection status.
- **Router**: Mocked via `vi.mock("@modern-js/runtime/router")``useNavigate` returns a spy, `useParams` returns test locale.
- **i18n**: Mocked via `vi.mock("@/i18n/provider.js")``useTranslation` returns a stub `t` function that echoes the key.
- **Environment**: Mocked via `vi.mock("@/env/index.js")` — returns test environment values.
## Vitest configuration
- Integration tests use `// @vitest-environment jsdom` pragma per file
- Test files at `tests/integration/online-board/*.test.tsx`
- Already included by vitest's `include` pattern (needs `tests/**/*.test.tsx` added)
## Dependencies
- `@testing-library/react` (already installed)
- `@testing-library/jest-dom` (to install)
- `jsdom` (already installed)
## Exit criteria
- All 6 test files pass with `pnpm test`
- 20-30 integration tests total
- No real HTTP calls or SignalR connections
- `pnpm typecheck` passes
- `pnpm lint` passes
- `pnpm build:standalone` passes
## Tasks
1. Install `@testing-library/jest-dom`
2. Update vitest config to include `tests/**/*.test.tsx`
3. Create shared test fixtures and helpers
4. Create all 6 test files
5. Verify: typecheck + lint + test + build
@@ -1,67 +0,0 @@
# Phase 3 -- Schedule Feature (Master Plan)
> **Parent:** React rewrite project
> **Depends on:** Phase 1 (foundation), Phase 2 (online board -- patterns to reuse)
## Overview
Port the Angular Schedule feature to the React codebase. The schedule is
structurally similar to the online board but differs in several key ways:
- **Date ranges** (dateFrom/dateTo) instead of single dates.
- **Round-trip** search with outbound + return param segments.
- **Catch-all** details route for variable-length multi-flight chains.
- **POST** for search (not GET).
- **No SignalR** -- schedule data is static.
- **Connections** filter (C0, C1, ...) in URL params.
## URL Format
### Search (route)
```
schedule/route/{dep}-{arr}-{dateFrom}-{dateTo}[-{timeFromTo}][-C{connections}]
```
### Round-trip search
```
schedule/route/{outbound-params}/{return-params}
```
### Details (catch-all)
Two formats coexist:
1. Simple: `schedule/{flight1-date}/{flight2-date}/...`
2. With airports: `schedule/{depCode}/{flight1-date}/{arrCode}/{depCode2}/{flight2-date}/{arrCode2}/...`
### Start page
```
schedule
```
## Sub-plans
| Sub-plan | Name | Deliverables |
|----------|------|-------------|
| **3A** | URL serializer/parser | `src/features/schedule/url.ts` + tests |
| **3B** | API + hooks | `api.ts`, `useScheduleSearch`, `useScheduleDetails`, `useScheduleCalendar` |
| **3C** | Route pages | 4 Modern.js route pages |
| **3D** | SEO + JSON-LD | `seo.ts`, `json-ld.ts` + tests |
| **3E** | Parity + integration | Parity harness registration, ~10 integration tests |
## Key types (new in `src/features/schedule/types.ts`)
- `IScheduleSearchParams` -- outbound search parameters
- `IScheduleDetailsParams` -- multi-flight details parameters
- `IScheduleResponse` -- `IFlight[]` (reuses online-board flight types)
- `IScheduleDetailsResponse` -- same as `IBoardResponse`
- `IScheduleCalendarParams` -- calendar day availability
## Execution order
3A -> 3B -> 3C -> 3D -> 3E (sequential, each builds on prior)
## Constraints
- Do NOT modify `ClientApp/`, ASP.NET, or `wwwroot/`.
- Update `src/features/schedule/index.ts` barrel.
- Update `src/mf/expose/Schedule.tsx` from stub to real component.
- Final verification: `pnpm typecheck && pnpm lint && pnpm test && pnpm build:standalone`.
@@ -1,29 +0,0 @@
# Phase 3A -- Schedule URL Serializer/Parser
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
> **Depends on:** Phase 2B (reuses parseFlightUrlParams, buildFlightUrlParams)
## Goal
TDD implementation of URL parser/builder for the schedule feature covering:
1. Start page
2. One-way route search
3. Round-trip route search
4. Multi-flight details (catch-all)
## Deliverables
1. `src/features/schedule/types.ts` -- schedule-specific types
2. `src/features/schedule/url.ts` -- parse/build functions
3. `src/features/schedule/url.test.ts` -- comprehensive tests
## URL Format Reference
### Route params: `{dep}-{arr}-{dateFrom}-{dateTo}[-{timeFromTo}][-C{connections}]`
### Details: `{flight1-date}[/{flight2-date}]...` or with airport codes interleaved
## Tasks
T1. Create schedule types
T2. Write URL test file (TDD -- tests first)
T3. Implement url.ts to pass all tests
@@ -1,18 +0,0 @@
# Phase 3B -- Schedule API + Hooks
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
> **Depends on:** 3A (types)
## Deliverables
1. `src/features/schedule/api.ts` -- three API functions
2. `src/features/schedule/api.test.ts` -- unit tests
3. `src/features/schedule/hooks/useScheduleSearch.ts`
4. `src/features/schedule/hooks/useScheduleDetails.ts`
5. `src/features/schedule/hooks/useScheduleCalendar.ts`
## API Endpoints
- `POST schedule/1` -- search (body: IScheduleSearchRequest)
- `GET schedule/details` -- multi-flight details (query: flights[], dates[], departure, arrival)
- `GET days/{date}/382/{param}/schedule/v1` -- calendar days
@@ -1,20 +0,0 @@
# Phase 3C -- Schedule Route Pages
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
> **Depends on:** 3A (URL), 3B (API/hooks)
## Deliverables
1. Schedule page components in `src/features/schedule/components/`
2. Modern.js route pages in `src/routes/[lang]/schedule/`
3. 4 routes: start, one-way, round-trip, catch-all details
## Route Structure
```
src/routes/[lang]/schedule/
page.tsx -- start page
route/[params]/page.tsx -- one-way search
route/[params]/[returnParams]/page.tsx -- round-trip search
[...flights]/page.tsx -- catch-all details
```
@@ -1,9 +0,0 @@
# Phase 3D -- Schedule SEO + JSON-LD Tests
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
> **Depends on:** 3C (seo.ts, json-ld.ts already created)
## Deliverables
1. `src/features/schedule/seo.test.ts` -- SEO builder tests
2. `src/features/schedule/json-ld.test.ts` -- JSON-LD builder tests
@@ -1,12 +0,0 @@
# Phase 3E -- Schedule Parity + Integration Tests
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
> **Depends on:** 3A-3D (all schedule code)
## Deliverables
1. URL parity test registered in harness
2. URL fixture corpus for schedule
3. Integration tests (~10)
4. Updated barrel (`index.ts`)
5. Updated MF expose (`Schedule.tsx`)
@@ -1,77 +0,0 @@
# Phase 4 -- Flights Map (Master Plan)
## Goal
Port the Angular `flights-map` feature (single `/flights-map` route) to the React/ModernJS codebase as a Module Federation remote component. The map uses Leaflet for interactive marker/polyline rendering and is behind the `flightsMap` feature flag.
## Angular analysis summary
- **Route:** `/flights-map` (start page with embedded Leaflet map)
- **Feature flag:** `flightsMap` (env-based)
- **APIs:** `destinations/v1` (search routes by departure/arrival), `days/.../flights-map/v1` (calendar availability)
- **Leaflet map:** markers (blue default, blue-small zoom-dependent, orange selected), polylines (solid direct, dashed connecting), popups, tooltips, zoom-aware layer visibility
- **Components:** start page container, map body (sole Leaflet consumer), filter panel, meta tags, loader/empty overlays
- **No SignalR** -- data is static, fetched on filter change
- **SEO:** meta tags only in Angular; design spec requires JSON-LD `WebPage` schema
## Sub-plans
### 4A -- Types + API + Hooks
**Files:** `src/features/flights-map/types.ts`, `api.ts`, `api.test.ts`, `hooks/useFlightsMapSearch.ts`, `hooks/useFlightsMapCalendar.ts`
- Define `IFlightRoute`, `IMapMarker`, `IMapPolyline`, `FlightsMapSearchParams`, `FlightsMapCalendarParams`
- Define `IDestinationsResponse`, `IFlightsMapDaysResponse`
- API: `searchDestinations(client, params)` -> `GET destinations/1`, `getFlightsMapCalendar(client, params)` -> `GET days/{date}/200/{route}/flights-map/v1`
- Hooks: `useFlightsMapSearch(params)`, `useFlightsMapCalendar(params)`
- Feature flag: `useFeatureFlag("flightsMap")` via env config
- TDD: ~10 tests for API functions
### 4B -- Leaflet Map Wrapper
**File:** `src/features/flights-map/components/MapCanvas.tsx`
- **Only file that imports `leaflet`** (design spec constraint)
- Wrapped in `React.lazy()` + `<ClientOnly>` (SSR-safe)
- Accepts `markers`, `polylines`, `onMarkerClick` etc. as props -- does not own state
- Renders markers (blue/orange icons), polylines (solid/dashed), popups, tooltips
- Great-circle arc calculation for polyline rendering
- Install `leaflet` + `@types/leaflet`
- No TDD (Leaflet requires real DOM)
### 4C -- Route Pages + Feature Components
**Files:**
- `src/routes/[lang]/flights-map/page.tsx` -- route page with SEO + feature flag guard
- `src/features/flights-map/components/FlightsMapStartPage.tsx` -- container (filter + map + loading/empty states)
- `src/features/flights-map/components/FlightsMapFilter.tsx` -- search filter (departure, arrival, connections, domestic/international toggles, date picker)
- `src/features/flights-map/components/ClientOnly.tsx` -- SSR-safe wrapper
### 4D -- SEO + JSON-LD + Parity
**Files:**
- `src/features/flights-map/seo.ts` -- `buildFlightsMapSeo(t, locale, canonicalOrigin)`
- `src/features/flights-map/json-ld.ts` -- JSON-LD `WebPage` schema builder
- `src/features/flights-map/seo.test.ts` -- ~5 integration tests
- Update barrel `src/features/flights-map/index.ts`
- Update `src/mf/expose/FlightsMap.tsx` from stub to real
## Dependency graph
```
4A (types/api/hooks) --> 4B (map canvas, needs types)
--> 4C (pages/components, needs hooks)
4A + 4B + 4C --------> 4D (SEO + barrel + MF expose update)
```
## Verification
```bash
pnpm typecheck && pnpm lint && pnpm test && pnpm build:standalone
```
## Constraints
- Do NOT touch `ClientApp/`, ASP.NET, `wwwroot/`
- No `Co-Authored-By` lines in commits
- ~8-12 commits total
@@ -1,111 +0,0 @@
# Phase 5 — Popular Requests MASTER Plan
> **This document is a plan INDEX, not an executable plan.** It lists the Phase 5 sub-plans, their dependency order, the contracts each sub-plan exports for downstream sub-plans to consume, and the shared files that cross sub-plan boundaries.
**Goal of Phase 5:** Port the Popular Requests feature from Angular to React, wire it into existing route pages, implement the `useSearchHistory` hook backed by `@/shared/storage`, and verify SEO + parity. Popular Requests is an embedded component (not a standalone routed page) — used inside the OnlineBoard and Schedule start pages. It also includes the language switcher (ported from Angular layout) and a per-language-namespaced search history hook.
**Phase 5 exit gate** (must pass before next phase starts):
- `PopularRequestsPanel` renders correctly with all 5 request modes: `FlightNumber`, `Route`, `Arrival`, `Departure`, `RouteWithBack`.
- `usePopularRequests` hook calls `GET /Requests/1/getpopular` and handles loading/error states.
- `useSearchHistory` hook persists search history via `@/shared/storage` with per-language namespacing (`afl_history_{lang}`).
- No direct `localStorage` access outside `src/shared/storage.ts` (enforced by ESLint `no-restricted-globals`).
- MF expose `PopularRequests.tsx` upgraded from stub to real component.
- Feature barrel `src/features/popular-requests/index.ts` exports all public surface.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm build:standalone` all green.
**Angular source analyzed:**
- **API:** `GET /api/Requests/1/getpopular` returns `IPopularRequest[]` (union of 4 discriminated types keyed by `RequestMode`).
- **Types:** `RequestMode` enum: `FlightNumber | Route | RouteWithBack | Departure | Arrival`. `IPopularRequestType`: `'Schedule' | 'Onlineboard'`. `IPopularRequest` = union of `IPopularRouteRequest | IPopularArrivalRequest | IPopularDepartureRequest | IPopularFlightNumberRequest`.
- **Components:** `PopularRequestsComponent` (container, fetches data, handles clicks + navigation), `PopularRequestComponent` (switch by mode), `ArrivalRequestComponent`, `DepartureRequestComponent`, `FlightNumberRequestComponent`, `RouteRequestComponent`, `RequestInfoComponent` (styled clickable span).
- **Behavior:** On click, populates filter state in `OnlineBoardFiltersStateService` / `ScheduleFiltersStateService` and navigates to `onlineboard` or `schedule` route. Uses `cityName` pipe (maps IATA code to city name via dictionaries).
- **Search History:** `SearchHistoryService` — in-memory array of `ISearchHistoryItem` with dedup-by-URL and prepend logic. Displayed on start pages. React version should persist to localStorage via `@/shared/storage`.
- **No dedicated route** — embedded inside `OnlineBoardStartPage` and `ScheduleStartPage`.
---
## Sub-plan inventory
| ID | Sub-plan | Estimated size | Description |
|---|---|---|---|
| **5A** | Types + API + hooks | Small (8-12 tasks) | `PopularRequest` types, `getPopularRequests` API fn, `usePopularRequests` hook |
| **5B** | Route page + components | Medium (12-18 tasks) | `PopularRequestsPanel`, mode-specific sub-components, MF expose update |
| **5C** | SEO + parity + integration tests | Small (6-10 tasks) | Verify no SEO regression, integration tests for the panel |
| **5D** | Search history hook | Small (8-12 tasks) | `useSearchHistory` with per-language localStorage namespacing via `@/shared/storage` |
---
## Dependency graph
```
┌──────────────────────────────┐
│ 5A Types + API + hooks │
│ src/features/popular- │
│ requests/{types,api,hooks} │
└──────────────┬───────────────┘
┌──────────────▼───────────────┐
│ 5B Components + MF expose │◄── depends on 5A types/hooks
│ src/features/popular- │
│ requests/components/ │
│ src/mf/expose/ │
└──────────────┬───────────────┘
┌──────────────▼───────────────┐
│ 5C SEO + parity + tests │◄── depends on 5B components
└──────────────────────────────┘
┌──────────────────────────────┐
│ 5D useSearchHistory hook │ (independent of 5A-5C)
│ src/shared/hooks/ │
└──────────────────────────────┘
```
5D is independent and can be executed in parallel with 5A-5C.
---
## Contracts exported per sub-plan
### 5A — Types + API + hooks
**Files created:**
- `src/features/popular-requests/types.ts``RequestMode`, `PopularRequestType`, `PopularRequest` union, sub-types
- `src/features/popular-requests/api.ts``getPopularRequests(client: ApiClient): Promise<PopularRequest[]>`
- `src/features/popular-requests/api.test.ts` — API function tests
- `src/features/popular-requests/hooks/usePopularRequests.ts` — React hook wrapping the API call
- `src/features/popular-requests/index.ts` — barrel update
**Consumed by:** 5B (types + hook), 5C (tests)
### 5B — Components + MF expose
**Files created:**
- `src/features/popular-requests/components/PopularRequestsPanel.tsx` — container component
- `src/features/popular-requests/components/PopularRequestItem.tsx` — mode-switching renderer
- `src/features/popular-requests/components/RequestInfo.tsx` — styled clickable span
**Files modified:**
- `src/mf/expose/PopularRequests.tsx` — stub replaced with real component
- `src/features/popular-requests/index.ts` — barrel update
**Consumed by:** 5C (integration tests), route pages (OnlineBoardStartPage, ScheduleStartPage)
### 5C — SEO + parity + integration tests
**Files created:**
- `src/features/popular-requests/components/PopularRequestsPanel.test.tsx` — component tests
**Consumed by:** Exit gate verification
### 5D — Search history hook
**Files created:**
- `src/shared/hooks/useSearchHistory.ts` — hook with per-language localStorage namespacing
- `src/shared/hooks/useSearchHistory.test.ts` — hook tests
**Files modified:**
- `src/features/popular-requests/index.ts` — re-export if appropriate
**Consumed by:** Start pages (OnlineBoardStartPage, ScheduleStartPage) in future integration
@@ -1,443 +0,0 @@
# Phase 6: Cutover & Decommission Runbook
**Version:** 1.0
**Date:** 2026-04-15
**Target:** Flip all traffic from Angular SPA to React Module Federation remote, soak for 1 week, then archive the Angular codebase.
---
## 1. Pre-Cutover Checklist
Every gate below must be **green** before starting the traffic ramp.
| # | Gate | Verified by | Status |
|---|------|-------------|--------|
| 1 | Phase 1 exit: Foundation complete (ModernJS SSR, MF 2.0, i18n, API client, SignalR, layout, SEO, analytics, logger, metrics, security, deploy) | Tech lead | [ ] |
| 2 | Phase 2 exit: Online Board feature parity confirmed (URL serializer, API hooks, SignalR wiring, routes, SEO, integration tests) | QA lead | [ ] |
| 3 | Phase 3 exit: Schedule feature parity confirmed | QA lead | [ ] |
| 4 | Phase 4 exit: Flights Map feature parity confirmed | QA lead | [ ] |
| 5 | Phase 5 exit: Popular Requests feature parity confirmed | QA lead | [ ] |
| 6 | Load test passed at 100 RPS sustained for 30 minutes with p95 < 500ms | Performance engineer | [ ] |
| 7 | Staging environment fully verified (all routes, SSR, MF remote loading) | QA lead | [ ] |
| 8 | Rollback procedure rehearsed in staging (< 1 minute switchover confirmed) | Ops engineer | [ ] |
| 9 | Monitoring dashboards operational (error rate, p95 latency, Web Vitals, SignalR health) | SRE | [ ] |
| 10 | Search Console coverage verified (no indexing regressions on staging) | SEO lead | [ ] |
| 11 | All `pnpm typecheck && pnpm lint && pnpm test && pnpm build:both` pass on the release branch | CI | [ ] |
| 12 | Incident communication plan agreed (who to notify, escalation path) | Team lead | [ ] |
| 13 | Customer sign-off for production traffic shift | Project manager | [ ] |
---
## 2. Proxy Rule Configuration Template
The actual LB/proxy technology depends on the customer's infrastructure. Below are placeholder templates for common setups. Replace `<REACT_UPSTREAM>` and `<ANGULAR_UPSTREAM>` with the real backend addresses.
### 2.1 nginx
```nginx
# --- Phase 6 cutover: traffic split ---
# Adjust the weight to control traffic ramp (see Section 3).
# weight=0 means no traffic to that upstream.
upstream react_backend {
server <REACT_UPSTREAM> weight=100;
}
upstream angular_backend {
server <ANGULAR_UPSTREAM> weight=0; # Set to 0 at 100% cutover
}
# Split block — used during ramp
split_clients "${remote_addr}${request_uri}" $backend {
# Adjust percentages during ramp (Section 3)
# 5% react_backend; # Step 1
# 25% react_backend; # Step 2
# 50% react_backend; # Step 3
100% react_backend; # Step 4 (final)
* angular_backend;
}
server {
listen 443 ssl;
server_name flights.example.com;
location / {
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for SignalR
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Health check — always probe the active backend
location /health {
proxy_pass http://react_backend/health;
}
}
```
### 2.2 HAProxy
```haproxy
# --- Phase 6 cutover: traffic split ---
frontend flights_frontend
bind *:443 ssl crt /etc/ssl/certs/flights.pem
default_backend react_backend
# During ramp, use ACL + random stick-table for percentage split:
# acl is_canary rand(100) lt 5 # 5%
# use_backend react_backend if is_canary
# default_backend angular_backend
backend react_backend
server react1 <REACT_UPSTREAM> check
option httpchk GET /health
http-check expect status 200
backend angular_backend
server angular1 <ANGULAR_UPSTREAM> check
# Set to "disabled" state after 100% cutover:
# server angular1 <ANGULAR_UPSTREAM> check disabled
```
### 2.3 Customer LB (generic)
```
# If the customer uses a proprietary load balancer or CDN (e.g., F5, AWS ALB, Cloudflare):
#
# 1. Create a weighted target group / origin pool with two backends:
# - React: weight = <RAMP_PERCENTAGE>
# - Angular: weight = <100 - RAMP_PERCENTAGE>
#
# 2. Attach both targets to the main listener rule for flights.example.com/*
#
# 3. Adjust weights per the schedule in Section 3.
#
# 4. At 100% cutover, remove the Angular target entirely.
```
---
## 3. Traffic Ramp Schedule
Total ramp duration: **72 hours** (3 days). Each step requires explicit go/no-go from the on-call engineer.
| Step | Time (T+) | React % | Angular % | Duration before next step | Go/No-Go by |
|------|-----------|---------|-----------|---------------------------|-------------|
| 1 | T+0h | 5% | 95% | 12 hours | On-call engineer |
| 2 | T+12h | 25% | 75% | 12 hours | On-call engineer |
| 3 | T+24h | 50% | 50% | 24 hours | On-call engineer |
| 4 | T+48h | 100% | 0% | Start soak period | Tech lead |
### Step Execution
For each step:
1. **Update proxy weights** per Section 2 templates
2. **Reload proxy config** (graceful reload, no dropped connections):
```bash
# nginx
nginx -t && nginx -s reload
# HAProxy
haproxy -c -f /etc/haproxy/haproxy.cfg && systemctl reload haproxy
```
3. **Verify the split** by checking access logs for both backends
4. **Monitor for the hold period** (Section 4)
5. **Record go/no-go decision** in the incident channel with timestamp
---
## 4. Monitoring Checklist During Ramp
Monitor these metrics continuously during each ramp step. Any breach triggers the hold or rollback procedure.
### 4.1 Error Rate
- [ ] Overall error rate (5xx) < 0.5% throughout ramp step
- [ ] No new error patterns in application logs
- [ ] No CSP violation spikes
- [ ] No unhandled promise rejections in client-side error tracking
### 4.2 Latency
- [ ] p50 latency within 10% of Angular baseline
- [ ] p95 latency < 500ms
- [ ] p99 latency < 2s
- [ ] Time to First Byte (TTFB) < 800ms
### 4.3 Web Vitals (Core Web Vitals from RUM / Dynatrace)
- [ ] LCP (Largest Contentful Paint) < 2.5s
- [ ] FID / INP (Interaction to Next Paint) < 200ms
- [ ] CLS (Cumulative Layout Shift) < 0.1
### 4.4 Search Console
- [ ] No indexing coverage drops
- [ ] No new crawl errors
- [ ] Structured data (JSON-LD) validated without errors
- [ ] No mobile usability regressions
### 4.5 SignalR Health
- [ ] SignalR hub connections stable (no reconnection storms)
- [ ] Real-time flight updates arriving within 2s of server push
- [ ] WebSocket upgrade success rate > 99%
### 4.6 Business Metrics
- [ ] Page views per session consistent with Angular baseline
- [ ] Bounce rate not elevated > 5% above baseline
- [ ] Analytics events firing (Yandex.Metrica, CTM, Variocube, Dynatrace)
### 4.7 Infrastructure
- [ ] CPU utilization < 70% on React nodes
- [ ] Memory utilization stable (no upward trend)
- [ ] No OOM kills
- [ ] Health check (`/health`) returning 200 on all React nodes
---
## 5. Rollback Procedure
**Target: < 1 minute to restore Angular traffic.**
### 5.1 Trigger Criteria
Roll back immediately if any of:
- Error rate exceeds 1% for more than 5 minutes
- p95 latency exceeds 2x Angular baseline for more than 10 minutes
- `/health` returns 503 on > 50% of React nodes
- SignalR connections drop and do not recover within 5 minutes
- Any S1 incident is declared
### 5.2 Rollback Steps
1. **Flip proxy weights back to Angular:**
```bash
# nginx — set react weight=0, angular weight=100
# Edit the split_clients block or upstream weights
nginx -t && nginx -s reload
# HAProxy — switch default_backend to angular_backend
haproxy -c -f /etc/haproxy/haproxy.cfg && systemctl reload haproxy
# Customer LB — set React target weight to 0, Angular to 100
```
2. **Verify Angular is serving traffic:**
```bash
curl -sI https://flights.example.com/ | grep -i server
# Should show the ASP.NET/Angular response headers
```
3. **Confirm health:**
- [ ] Error rate returning to baseline
- [ ] p95 latency returning to baseline
- [ ] `/health` returning 200
4. **Notify stakeholders** in the incident channel
5. **Post-mortem:** File within 24 hours. Determine root cause before attempting another ramp.
### 5.3 Post-Rollback
- Do NOT re-attempt ramp until the root cause is identified and fixed
- Re-run the full pre-cutover checklist (Section 1) before the next attempt
- Consider a smaller initial percentage (2% instead of 5%) for the retry
---
## 6. One-Week Soak Criteria
After reaching 100% React traffic (T+48h), maintain for **7 calendar days** before proceeding to decommission.
### 6.1 Soak Pass Criteria
All of these must be true for the entire 7-day period:
| Criterion | Threshold | How to verify |
|-----------|-----------|---------------|
| Angular traffic | **Zero hits** in Angular access logs | `grep -c "angular_backend" /var/log/nginx/access.log` returns 0 for each day |
| Error rate | < 0.1% (5xx responses) | Monitoring dashboard daily average |
| p95 latency | < 500ms | Monitoring dashboard daily p95 |
| p99 latency | < 2s | Monitoring dashboard daily p99 |
| Core Web Vitals | All "Good" threshold | RUM / Dynatrace daily report |
| Search Console | No coverage regression | Weekly Search Console report |
| SignalR health | No reconnection storms, < 0.1% dropped connections | SignalR hub metrics |
| Analytics parity | Event counts within 5% of pre-cutover Angular baseline | Analytics dashboard comparison |
| OOM / restart count | Zero unexpected container restarts | Container orchestrator logs |
### 6.2 Soak Failure
If any criterion is breached during the soak:
1. **Do NOT immediately roll back** unless it meets Section 5.1 trigger criteria
2. Investigate the root cause
3. If a fix is deployed, restart the 7-day soak clock
4. If the issue is transient and recovers within 1 hour, document but do not restart the clock
### 6.3 Soak Sign-Off
At the end of the 7-day soak, obtain written sign-off from:
- [ ] Tech lead
- [ ] QA lead
- [ ] Project manager
- [ ] Customer representative (if required by contract)
---
## 7. Angular Decommission Steps
Only proceed after soak sign-off (Section 6.3).
### 7.1 Git Tag
Tag the last commit that includes the Angular code:
```bash
# Identify the current HEAD (should be the release branch tip)
git log --oneline -1
# Create an annotated tag
git tag -a angular-final -m "Final state of Angular codebase before decommission"
# Push the tag to the remote
git push origin angular-final
```
### 7.2 Archive Branch
Create an archive branch preserving the full Angular codebase:
```bash
# Create archive branch from current HEAD
git checkout -b archive/angular-spa
# Push to remote
git push -u origin archive/angular-spa
# Return to the main development branch
git checkout plan/react-rewrite # or main, depending on workflow
```
### 7.3 Remove Angular / ASP.NET Files
**WARNING: Only do this after customer approval. This runbook does NOT execute this step automatically.**
Files and directories to remove (review with the customer first):
```
ClientApp/ # Angular SPA source
src/
angular.json
karma.conf.js
tsconfig*.json (Angular-specific)
package.json (Angular)
cypress/
.storybook/
...
Aeroflot.Flights.Web.csproj # ASP.NET host project
Startup.cs # ASP.NET startup
Program.cs # ASP.NET entry point
Controllers/ # ASP.NET controllers (if any)
wwwroot/ # Static assets served by ASP.NET
appsettings*.json # ASP.NET configuration
*.sln # .NET solution file
```
Steps:
1. Create a new branch for the cleanup:
```bash
git checkout -b chore/remove-angular-code
```
2. Remove the files listed above (verify the list with `git status` before committing)
3. Update `.gitignore` to remove Angular/ASP.NET-specific entries
4. Run the full verification suite:
```bash
pnpm typecheck && pnpm lint && pnpm test && pnpm build:both
```
5. Commit and create a PR for review
6. After merge, verify production deployment is unaffected
### 7.4 Infrastructure Cleanup
After the Angular code is removed and deployed:
- [ ] Decommission Angular backend VMs / containers
- [ ] Remove Angular upstream from load balancer configuration
- [ ] Remove Angular-specific monitoring dashboards (or archive them)
- [ ] Update DNS records if Angular was on a separate subdomain
- [ ] Revoke Angular-specific secrets / certificates if any
- [ ] Update architecture diagrams and documentation
### 7.5 Post-Decommission Verification
- [ ] All routes return correct responses from React
- [ ] No references to Angular backend in proxy configs
- [ ] No orphaned infrastructure resources
- [ ] Documentation updated to reflect React-only architecture
- [ ] Incident runbook (docs/superpowers/phase-1/runbook.md) updated to remove Angular references
---
## Appendix A: Quick Reference Commands
```bash
# Check which backend is serving a request
curl -sI https://flights.example.com/ | grep -E "Server|X-Powered-By"
# Check Angular access log for hits (should be zero during soak)
grep -c "angular_backend" /var/log/nginx/access.log
# Verify React health
curl -s https://flights.example.com/health | jq .
# Check MF manifest is accessible
curl -sI https://flights.example.com/mf-manifest.json
# Verify SSR is working (check for rendered HTML in response body)
curl -s https://flights.example.com/ru/onlineboard | grep -c "data-mf-expose"
# Check SignalR hub connectivity
curl -s https://flights.example.com/signalr/negotiate -X POST | jq .
```
## Appendix B: Contacts
| Role | Name | Contact |
|------|------|---------|
| Tech lead | TBD | TBD |
| On-call engineer | TBD | TBD |
| QA lead | TBD | TBD |
| SRE | TBD | TBD |
| Customer representative | TBD | TBD |
| Incident channel | TBD | TBD |
---
## Appendix C: Timeline Summary
```
Day 0 : Pre-cutover checklist complete, customer sign-off
Day 0-3 : Traffic ramp (5% -> 25% -> 50% -> 100%)
Day 3-10 : One-week soak at 100% React
Day 10 : Soak sign-off
Day 10+ : Angular decommission (git tag, archive, file removal)
Day 11+ : Infrastructure cleanup
```
@@ -0,0 +1,525 @@
# Aeroflot Flights Web: Angular to React Migration -- Design Spec
## Overview
Rewrite the Aeroflot Flights Web application from Angular 12 to React 18+, deployed as a Module Federation 2.0 remote micro-frontend. The React app runs standalone (own routing, own layout) and exposes features for the customer's host apps (Web, PWA) to consume via `mf-manifest.json`.
## Technology Stack
| Component | Technology |
|---|---|
| Framework | ModernJS (SSR, streaming mode) |
| Bundler | Rspack (via Rsbuild) |
| Module Federation | MF 2.0 with `mf-manifest.json` |
| UI Library | React 18+ with Concurrent Mode |
| Component Library | PrimeReact (port of existing PrimeNG look) |
| State (server) | TanStack Query v5 |
| State (client) | Zustand |
| Styling | CSS Modules + postcss-prefix-selector |
| Maps | React-Leaflet |
| i18n | react-i18next (reuse existing JSON files) |
| Real-time | @microsoft/signalr |
| Analytics | analytics (David Wells) with plugins |
## 1. Project Structure & Module Federation
```
react-app/
+-- modern.config.ts
+-- module-federation.config.ts
+-- src/
| +-- entry.server.tsx
| +-- entry.client.tsx
| +-- App.tsx
| +-- routes/
| | +-- layout.tsx
| | +-- onlineboard/
| | | +-- page.tsx
| | | +-- flight.[params].tsx
| | | +-- departure.[params].tsx
| | | +-- arrival.[params].tsx
| | | +-- route.[params].tsx
| | | +-- [params].tsx
| | +-- schedule/
| | | +-- page.tsx
| | | +-- [params].tsx
| | +-- flights-map/
| | | +-- page.tsx
| | +-- error/
| | +-- 404.tsx
| +-- features/
| | +-- online-board/
| | | +-- components/
| | | +-- hooks/
| | | +-- services/
| | | +-- types.ts
| | | +-- index.ts
| | +-- schedule/
| | | +-- components/
| | | +-- hooks/
| | | +-- services/
| | | +-- types.ts
| | | +-- index.ts
| | +-- flights-map/
| | | +-- index.ts
| | +-- popular-requests/
| | +-- index.ts
| +-- shared/
| | +-- api/
| | +-- hooks/
| | +-- stores/
| | +-- types/
| | +-- utils/
| | +-- seo/
| | +-- analytics/
| +-- ui/
| | +-- calendar-input/
| | +-- card/
| | +-- date-tabs/
| | +-- time-selector/
| | +-- toggle-switch/
| | +-- icons/
| +-- i18n/
| | +-- config.ts
| | +-- locales/
| +-- styles/
| +-- theme/
| +-- global.module.css
+-- public/
+-- package.json
+-- tsconfig.json
```
### Module Federation Configuration
```ts
// module-federation.config.ts
exposes: {
'./App': './src/App.tsx',
'./OnlineBoard': './src/features/online-board/index.ts',
'./Schedule': './src/features/schedule/index.ts',
'./FlightsMap': './src/features/flights-map/index.ts',
'./PopularRequests': './src/features/popular-requests/index.ts',
}
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
'@microsoft/signalr': { singleton: true, requiredVersion: '^9.0.0' },
}
```
### Two Modes of Operation
- **Standalone**: Full React app with own routing -- used for development, testing, and direct access.
- **Remote**: Host apps import individual features or `./App` via `mf-manifest.json`. Each feature is a self-contained React component with props-based API.
### Feature Isolation Rule
Features never import from each other. They only import from `shared/`, `ui/`, and `i18n/`. Each feature exposes a single entry component with well-defined props. This makes future MF extraction (breaking a feature into its own remote) a build/deployment change, not a code change.
## 2. Data Flow & State Architecture
### Zustand Stores (client/UI state)
```ts
// Per-feature filter stores
useOnlineBoardFilters // flightNumber, suffix, timeRange, date, departure, arrival
useScheduleFilters // departure, arrival, dateRange, directOnly, withReturn
useFlightsMapFilters // departure
// App-level stores
useSettingsStore // environment config, feature flags, refresh intervals
useUserLocationStore // geolocation result
useSearchHistoryStore // persisted search history (localStorage)
```
### TanStack Query (server state)
```ts
// Query keys: [feature, entity, params]
useFlightsQuery({ date, departure, arrival })
useFlightDetailsQuery({ flightNumber, date })
useFlightDaysQuery({ date, param, scope })
useScheduleQuery({ departure, arrival, dates })
useScheduleDaysQuery({ date, param })
useDestinationsQuery({ departure })
usePopularRequestsQuery()
```
TanStack Query replaces Angular's `CacheService` (response caching), manual `_loading` flags (loading/error states), and background refetching logic.
### Data Flow
1. User selects filters -> Zustand store updates
2. URL updates to reflect filter state (URL is source of truth)
3. Route component reads URL params -> passes to TanStack Query hook
4. Query fetches from REST API, caches result
5. SignalR subscribes to relevant hub events
6. Real-time update arrives -> query invalidated -> silent background refetch -> UI updates
## 3. Routing & URL Structure
### URL Patterns (unchanged from Angular)
```
/onlineboard/ # Start page
/onlineboard/flight/{flightNumber}-{flightDate} # Flight number search
/onlineboard/departure/{departure}-{date}-{from}{to} # Departure search
/onlineboard/arrival/{arrival}-{date}-{from}{to} # Arrival search
/onlineboard/route/{dep}-{arr}-{date}-{from}{to} # Route search
/onlineboard/{flightNumber}-{flightDate} # Flight details
/schedule/ # Start page
/schedule/{departure}-{arrival}-{dateRange} # Search results
/schedule/{flightNumber}-{flightDate} # Flight details
/flights-map/ # Map view (feature-flagged)
/error/404 # Not found
```
### Language Handling
Language detected from: (1) URL prefix if host provides one (remote mode), (2) browser locale / stored preference (standalone mode), (3) configurable via props.
Language prefixes (`/ru`, `/en`, `/es`, `/fr`, `/it`, `/ja`, `/ko`, `/zh`, `/de`) redirect to `/onlineboard`.
### URL <-> State Synchronization
URL is the source of truth for search state. `useUrlParams()` parses route params into typed filter objects. `useNavigateWithParams()` builds URL from filter state and navigates.
### Route Validation
Wrapper components validate URL params before rendering (replaces Angular route guards):
- `isValidDate(date)`
- `isValidStation(code)` -- 3-letter IATA
- `isValidFlightNumber(num)` -- 1-4 digits + optional letter suffix
- `isValidTimeRange(from, to)`
Invalid params redirect to `/error/404`.
### Feature Flag Guard
`flights-map` route is gated by `settingsStore.features.flightsMap`. If disabled, redirects to `/onlineboard`.
### Settings Resolution
Root layout fetches settings via TanStack Query before rendering any feature (replaces Angular's `SettingsResolver`).
## 4. SSR & SEO
### SSR Strategy
ModernJS streaming SSR with Data Loader pattern (Remix-inspired):
- Each route defines a `page.data.ts` exporting a `loader` function that runs on the server only.
- Loaders prefetch TanStack Query data with `queryClient.prefetchQuery()` and return `dehydrate(queryClient)`.
- Page components wrap content in `<HydrationBoundary state={dehydratedState}>` -- client picks up cached data without refetch.
- New `QueryClient` per request to prevent data leaks between users.
- `staleTime` set to match cache TTL (10-30s) to prevent immediate client refetch after hydration.
- Bot detection: ModernJS detects crawlers and serves full (non-streamed) HTML.
- Fallback: if SSR fails, ModernJS auto-falls back to CSR.
### Head Management
React 19 native metadata hoisting -- no library needed. `<title>`, `<meta>`, `<link>` rendered anywhere in the component tree are automatically hoisted to `<head>`.
When running as MF remote: the remote exposes a metadata contract for the host to read during SSR.
### JSON-LD Structured Data
`schema.org/Flight` with `Airline` and `Airport` sub-objects. For list pages, wrapped in `ItemList`.
Fields: `flightNumber`, `airline` (name, iataCode), `departureAirport` / `arrivalAirport` (name, iataCode), `departureTime`, `arrivalTime` (ISO 8601 with timezone), `estimatedFlightDuration`, `departureTerminal`, `arrivalTerminal`.
Rendered server-side via `<script type="application/ld+json">`.
### OpenGraph
Per-page OG tags: `og:title`, `og:description`, `og:url`, `og:type`, `og:locale`, `og:locale:alternate` (for all 9 languages). Twitter Cards with `summary_large_image`.
### Per-Page SEO
| Page | Title | Structured Data | noRobots |
|---|---|---|---|
| Board start | "Online Timetable -- Aeroflot" | None | No |
| Flight search results | "Flights {dep} -> {arr} -- {date}" | ItemList of Flight | No |
| Flight details | "Flight SU-{num}: {dep} -> {arr}" | Flight | Yes |
| Schedule start | "Flight Schedule -- Aeroflot" | None | No |
| Schedule results | "Schedule {dep} -- {arr}" | ItemList of Flight | No |
| Flights map | "Flight Map -- Aeroflot" | None | No |
## 5. Analytics, Logging & Monitoring
### Analytics Abstraction
[`analytics`](https://github.com/DavidWells/analytics) library as unified dispatcher with custom plugins per provider. Page views tracked automatically on route changes via `useLocation()`.
### Per-System Integration
**Yandex.Metrika**: `Modern-Yandex-Metrika` with `delay: 'onload'` for LCP optimization. Client-only. SPA page views via `ym(COUNTER_ID, 'hit', url)`. Goals via `ym(COUNTER_ID, 'reachGoal', 'goalName', params)`.
**Dynatrace (Klyuch-Astrom)**: Host app injects OneAgent -- remote does NOT load its own copy. OneAgent auto-captures XHR/fetch, DOM mutations, route changes. Custom business actions via `dtrum` API. TypeScript types via `@dynatrace/dtrum-api-types`.
**Variocube (Variokub)**: Server-side variant resolution via Varioqub usersplit API in ModernJS loader. Variant resolved before render -- zero layout shift. Variant assignment cached per visitor cookie.
**CTM**: Async script injection. Re-trigger `CallTrackingMetrics.swapNumbers()` on route changes. SSR HTML contains original business number (correct for SEO).
### Logging
Custom structured logger with batch shipping:
- Batch size: 50 entries, flush every 5 seconds
- `sendBeacon` on `visibilitychange` (when `hidden`) for reliable delivery on page unload
- Pluggable format -- customer's log format spec TBD (will be provided by customer separately)
- Sampling: 100% errors, 100% warnings, 20% info in production
- `traceId` for correlation with backend OpenTelemetry traces
- No PII without explicit consent
### Web Vitals
Google's `web-vitals` v5 library, client-only after hydration. Tracks: LCP, INP, CLS, FCP, TTFB. Reports via `sendBeacon` to `/api/vitals`.
## 6. Style Isolation & Theming
### Three-Layer Isolation
**Layer 1 -- CSS Modules**: All custom components use `.module.css` files. Class names hashed at build time. Zero runtime cost, SSR-safe.
**Layer 2 -- postcss-prefix-selector**: All global/third-party CSS (PrimeReact) prefixed with `.afl-flights`. Every PrimeReact selector becomes `.afl-flights .p-button`, etc.
**Layer 3 -- PrimeReact overlay containment**: `PrimeReactProvider` with `appendTo: 'self'` globally. All overlays (dropdowns, dialogs, tooltips) render inside the scoped wrapper.
### Boundary Reset
Explicitly set inherited properties on boundary element (NOT `all: initial` which breaks `display`, `box-sizing`, etc.):
```css
.afl-flights {
font-family: 'Aeroflot Sans', sans-serif;
font-size: 16px;
line-height: 1.5;
color: var(--afl-text-color, #1a1a1a);
direction: ltr;
text-align: left;
isolation: isolate; /* New stacking context -- prevents z-index conflicts */
}
```
### Responsive Layout: Container Queries
Container queries (not media queries) for all component-level responsiveness. A MF remote doesn't control the viewport -- it might be full-width or in a sidebar. Container queries respond to actual available space.
```css
.afl-flights { container-type: inline-size; container-name: flights-root; }
@container flights-root (min-width: 768px) { ... }
@container flights-root (min-width: 1024px) { ... }
```
Nested containers for fine-grained responsiveness (e.g., `.flightCard` as its own container).
Media queries reserved only for: `prefers-color-scheme`, `prefers-reduced-motion`, `print`.
Browser support: 95%+ (Chrome 105+, Firefox 110+, Safari 16+).
### Theming: Three-Tier CSS Custom Properties
```
Tier 1 -- Primitive tokens: --afl-blue-500, --afl-gray-100, etc.
Tier 2 -- Semantic tokens: --afl-primary, --afl-surface, --afl-text-color
Tier 3 -- Component tokens: --afl-btn-bg, --afl-card-bg, --afl-card-shadow
```
Host app overrides any tier by setting variables on `.afl-flights`. Theme variants via `data-theme` attribute (`light`, `dark`).
PrimeReact theme integration: map tokens to PrimeReact design token system (`--p-primary-color: var(--afl-primary)`).
### ModernJS/Rspack Config
- `output.cssModules.localIdentName: '[local]--[hash:base64:5]'` -- deterministic for SSR hydration
- `output.cssModules.namedExport: true` -- better tree-shaking
- Streaming SSR mode (required for MF with ModernJS)
## 7. SignalR Real-Time Integration
### Connection Management
React Context + singleton `HubConnection`. Dynamic `import('@microsoft/signalr')` inside `useEffect` -- the library references `XMLHttpRequest`/`WebSocket` at module level, which crashes during SSR.
### Reconnection Policy
Custom `IRetryPolicy` with exponential backoff (1s, 2s, 4s, 8s... capped at 30s), **never gives up**. The default policy stops after 4 attempts -- unacceptable for a 24/7 flight board.
### Event Handling
`useSignalREvent` hook subscribes to hub events with a ref-based callback to avoid stale closures.
Feature-level hooks:
- `useFlightBoardRealtime(date, departure?, arrival?)` -- subscribes to `RefreshDate`, invalidates flight board query
- `useFlightDetailsRealtime(flightId)` -- subscribes to `Refresh`, invalidates flight details query
### Re-Subscribe After Reconnect
SignalR does not persist subscriptions across reconnects. A subscription registry in the provider tracks active subscriptions. `onreconnected` callback re-invokes all registered subscriptions.
### Inactive Tab Handling
Chrome throttles background tabs, which can drop SignalR connections. Rely on `withAutomaticReconnect` (infinite retry) + TanStack Query's `refetchOnWindowFocus: true`. When user returns to tab: auto-reconnect fires, subscriptions re-registered, stale data refetched.
### TanStack Query Config
Flight queries use `staleTime: Infinity` -- don't auto-refetch since all updates come via SignalR. `refetchOnWindowFocus: true` handles tab-return freshness.
### Module Federation
Remote owns the connection. `@microsoft/signalr` marked `singleton: true` in MF shared config to avoid duplicate bundles.
### Connection Status UI
Non-blocking banner: "Real-time updates paused. Reconnecting..." (reconnecting state), "Connection lost. Data may be outdated." (disconnected state).
## 8. Performance & Scaling
### Target: 100 RPS
### Three-Tier Caching
```
Client -> CDN (10s cache + 50s stale-while-revalidate)
| MISS
v
Redis (full-page HTML cache, 10-15s TTL)
| MISS
v
ModernJS SSR (streaming render)
| prefetch
v
TanStack Query server-side (API data cache, 10-30s)
| MISS
v
REST API
```
With caching, actual SSR renders happen once per 10-15 seconds per unique URL. 100 RPS is met by CDN/Redis alone.
### CDN Headers
```
Cache-Control: public, s-maxage=10, stale-while-revalidate=50, max-age=0
Vary: Accept-Language
```
### Code Splitting
Route-based splitting via ModernJS (not `React.lazy()` which does not work in SSR). Each route directory = separate chunk. MF remote loading handled by federation runtime.
### Bundle Size Budget
| Asset | Budget (gzipped) |
|---|---|
| MF remote entry | < 50 KB |
| Total remote bundle | < 200 KB |
| Per-page JS | < 400 KB |
Optimization: individual PrimeReact imports, lazy-load React-Leaflet behind feature flag, MF 2.0 tree shaking with `federationRuntime: 'hoisted'`, only share `react`, `react-dom`, `@microsoft/signalr`.
### Node.js Scaling
- SSR render: ~50ms per request
- Single process: ~20 RPS
- Target: 2 containers x 4 cores = 8 processes -> ~160 RPS (60% headroom)
- `--max-old-space-size=1536` (1.5 GB heap on 2 GB container)
- PM2 cluster mode with `--max-memory-restart 1G`
### Geographic Distribution
App is fully stateless -- no server-side sessions, no sticky sessions. Deploy to multiple regions behind global load balancer. Redis per region for SSR cache.
### Performance Monitoring
**CI/CD**: `size-limit` for bundle budget, Rspack `performance.hints: 'error'`, Lighthouse CI for Web Vitals.
**Production**: SSR render time p50/p95/p99 (alert p95 > 200ms), cache hit rate (target > 90%), Web Vitals (LCP <= 2.5s, INP <= 200ms, CLS <= 0.1, TTFB <= 800ms), memory per pod (alert 80%), error rate (alert > 1%).
## 9. Features to Port
### Online Board
- Real-time flight status display with search by flight number, route, departure, arrival
- SignalR integration for live updates
- Flight details view with boarding status, leg information, codeshare data
- Day navigation, time range filtering
- Popular requests widget
### Schedule
- Flight schedule search by route over date ranges (up to 330 days)
- Return flight support
- Direct-only filter
- Accordion-based results display
- Flight details view
### Flights Map
- Interactive Leaflet map showing available destinations from departure point
- Feature-flag gated (`features.flightsMap`)
- Destination list with connection details
### Popular Requests
- Shared widget showing trending searches across all features
- Generic request components (flight number, route, departure, arrival)
## 10. Data Types
Port the existing TypeScript types from Angular's `/typings/` directory:
- `IFlight = ISimpleFlight | IConnectingFlight` (discriminated union)
- `ISimpleFlight = IDirectFlight | IMultiLegFlight`
- `RouteType`: DIRECT, MULTI_LEG, CONNECTING
- `FlightStatus`: SCHEDULED, SENT, IN_FLIGHT, LANDED, ARRIVED, DELAYED, CANCELLED, UNKNOWN
- `RequestMode`: FLIGHT_NUMBER, ROUTE, SCHEDULE_ROUTE_BOTH_DIRECTIONS, DEPARTURE, ARRIVAL
- API response types: `IBoardResponse`, `IScheduleResponse`, `IDaysResponse`, `IDestinationsResponse`
## 11. API Endpoints
All endpoints proxied under `/api` in development, real URLs in production.
```
GET /api/flights/v1.1/{lang}/board # Flight board
GET /api/flights/v1.1/{lang}/onlineboard/details # Flight details
GET /api/flights/v1.1/{lang}/days/{date}/31/{param}/board # Available days (board)
GET /api/flights/v1.1/{lang}/schedule/1 # Schedule search
GET /api/flights/v1.1/{lang}/days/{date}/382/{param}/schedule # Available days (schedule)
GET /api/flights/v1.1/{lang}/destinations/1 # Map destinations
GET /api/flights/v1.1/{lang}/days/{date}/200/{param}/flights-map # Map days
GET /api/Requests/1/getpopular # Popular requests
```
## 12. i18n
9 languages: ru, en, de, fr, es, it, ja, ko, zh.
Existing JSON translation files reused directly -- simple nested key-value format compatible with react-i18next without reformatting.
Calendar locale data registered per language.
## 13. Environment Configuration
Per-environment settings (mirroring Angular's `environment*.ts`):
- `apiRootUrl`, `wsRootUrl`, `mapApiUrl`
- `urlForTrackerHub` (SignalR hub)
- `urlForChatBot`
- `appInsights` (instrumentationKey, application, category, env)
- `refreshPauseMin: 15`, `refreshStopMin: 60`
- `boardCalendarDatesEnabledCountBack: 1`, `boardCalendarDatesEnabledCountForward: 14`
- `scheduleCalendarDatesEnabledCountBack: 1`, `scheduleCalendarDatesEnabledCountForward: 330`
- `features: { flightsMap: boolean }`
- Ticket purchase time windows (prod only)
## 14. Non-Functional Requirements
| Requirement | Implementation |
|---|---|
| 100 RPS | Three-tier caching (CDN -> Redis -> SSR), horizontal scaling |
| 24/7 availability | Stateless app, geo-distributed VMs, < 6h recovery |
| Security/isolation | CSS Modules + prefix scoping, `isolation: isolate`, `appendTo: 'self'` |
| SEO | SSR, JSON-LD, OpenGraph, canonical URLs |
| Responsive | Container queries, fluid layout |
| Logging | Structured frontend logs -> customer's logging system |
| Monitoring | Web Vitals, SSR metrics, error rates -> aggregator |
| Multi-platform | Web + PWA embedding via Module Federation |
@@ -0,0 +1,781 @@
# E2E Test Suite Design: Aeroflot Flights Web (Angular → React)
**Date:** 2026-04-04
**Status:** Design Approved
**Scope:** 200-300 comprehensive e2e tests covering all UI elements and interactions
---
## 1. Overview
This document specifies a comprehensive end-to-end test suite for the Aeroflot Flights Web application, covering both the current Angular implementation (ClientApp/) and the new React implementation (react-app/). The tests verify feature parity between the two versions and ensure all UI elements, interactions, and edge cases function correctly.
**Approach:** Write all tests against Angular first, validate 100% pass rate, then adapt and run against React (with both mocked and real APIs).
**Total Test Count:** 200-300 tests
**Execution Time:** 8-15 minutes per suite (mocked API), 10-20 minutes (real API)
---
## 2. Test Architecture & File Organization
### 2.1 Directory Structure
```
ClientApp/cypress/
├── integration/
│ ├── features/
│ │ ├── online-board.cy.ts (~60-70 tests)
│ │ ├── schedule.cy.ts (~50-60 tests)
│ │ ├── flights-map.cy.ts (~30-40 tests)
│ │ ├── popular-requests.cy.ts (~20-30 tests)
│ │ ├── i18n.cy.ts (~15-20 tests)
│ │ └── error-states.cy.ts (~25-30 tests)
│ └── responsive.cy.ts (~30-40 tests)
├── support/
│ ├── commands.ts (custom Cypress commands)
│ ├── page-objects/
│ │ ├── online-board.po.ts
│ │ ├── schedule.po.ts
│ │ ├── flights-map.po.ts
│ │ ├── common.po.ts
│ │ └── index.ts
│ ├── fixtures.ts (mock data, test cities, flights)
│ └── index.ts
└── tsconfig.json
react-app/cypress/
├── integration/
│ ├── features/
│ │ ├── online-board.cy.ts (adapted from Angular)
│ │ ├── schedule.cy.ts
│ │ ├── flights-map.cy.ts
│ │ ├── popular-requests.cy.ts
│ │ ├── i18n.cy.ts
│ │ └── error-states.cy.ts
│ └── responsive.cy.ts
├── support/
│ ├── commands.ts (same as Angular)
│ ├── page-objects/ (may differ from Angular if DOM differs)
│ └── fixtures.ts (same as Angular)
└── tsconfig.json
```
### 2.2 Test Organization
Each spec file is organized as:
```typescript
// Example: online-board.cy.ts
describe('Online Board Feature', () => {
describe('Arrival Tab', () => {
describe('City Input', () => {
it('should accept manual city entry');
it('should show validation error for empty input');
it('should handle special characters gracefully');
// ... more tests
});
describe('Date Picker', () => {
it('should accept valid future dates');
it('should reject past dates');
// ... more tests
});
describe('Search & Results', () => {
it('should display flight results after successful search');
it('should show loading state during API call');
// ... more tests
});
});
describe('Departure Tab', () => {
// ... similar structure
});
// ... more features
});
```
---
## 3. Test Scope by Feature
### 3.1 Online Board (~60-70 tests)
**Arrival Tab (~20 tests):**
- City input: manual entry, dropdown selection, validation errors, empty input, special characters
- Date picker: valid dates, future dates, past dates, invalid formats, today, edge dates
- Search button: valid search, missing fields, network error, loading state, disabled state
- Results display: flight list rendering, correct count, flight details modal, pagination
**Departure Tab (~20 tests):**
- Same test coverage as Arrival (mirror feature)
**Flight Search/Filter (~15 tests):**
- Flight number input: valid format (e.g., "SU 123"), invalid format, special characters, clear button
- Autocomplete behavior: suggestions appear, filtering works, arrow key navigation, tab navigation
- Form submission with partial filters
**Flight Details Panel (~10 tests):**
- Modal opens on flight click, displays all correct flight info
- Modal closes on X button, escape key, outside click
- Links (airline, gate, terminal) navigate correctly
- Back button returns to results with filters preserved
### 3.2 Schedule (~50-60 tests)
**Search Page (~25 tests):**
- Origin autocomplete: manual entry, dropdown, validation
- Destination autocomplete: same as origin
- Date range picker: start date, end date, single day, full range, invalid ranges
- Passenger count: input, increment/decrement, max/min bounds
- Form submission: valid, incomplete, network error, empty results
**Flight Details Page (~20 tests):**
- Flight info displays: departure, arrival, duration, airline, flight number
- Timing details: gate, terminal, check-in time, boarding time
- Seat map: renders, interactive, can select seat
- Price info: base price, taxes, total, currency formatting
- Navigation: previous flight, next flight, back to search
**Filters & Sorting (~10-15 tests):**
- Time range filter: apply, clear, validate boundaries
- Airline filter: select multiple, deselect all, apply
- Price range filter: min/max sliders, apply
- Sorting: by departure time, duration, price (ascending/descending)
### 3.3 Flights Map (~30-40 tests)
**Map Rendering (~15 tests):**
- Map loads and is interactive
- Flight destination markers display
- Marker clustering at certain zoom levels
- Pan and zoom work correctly
- Geolocation button works (if enabled)
**Destination List (~15 tests):**
- List items render with destination name, flight count
- Click destination: highlights on map, center map on marker
- Search/filter in list: filters by city name
- Empty state when no destinations
**Map Interactions (~10 tests):**
- Click marker: shows popup with flight info
- Click destination in list: highlights on map, centers view
- Hover effects on markers and list items
- Popup contains: destination name, flight count, quick link to search
### 3.4 Popular Requests Widget (~20-30 tests)
- Widget renders on initial page load
- Displays all popular request items (mock data)
- Click item: navigates to search with correct parameters
- API fallback to mock data works
- Empty state handling (no popular requests)
- Widget positioning and styling correct
### 3.5 Internationalization (i18n) (~15-20 tests)
- Language switcher visible and functional (all 9 languages: ru, en, es, fr, it, ja, ko, zh, de)
- Switching language: page updates all text, no hard-coded strings visible
- Language persistence: localStorage remembers selection
- Date formats match locale (e.g., DD.MM.YYYY for ru, MM/DD/YYYY for en)
- Number formatting matches locale (decimal separator, thousands separator)
- Text truncation on narrow screens doesn't break layout
- All UI elements have translations (no missing keys)
### 3.6 Error States (~25-30 tests)
- Network errors: 404 (not found), 500 (server error), 503 (service unavailable)
- Timeout handling: API call exceeds timeout threshold
- Empty results: no flights found for search
- Validation errors: required fields missing, invalid input format
- Loading states: loader visible, correct messaging
- Recovery: retry button works, clears error after successful retry
- SignalR connection failures: connection lost message, auto-reconnect attempt, manual reconnect button
### 3.7 Responsive/Mobile (~30-40 tests)
**Mobile Viewport (375x667 - iPhone SE):**
- All text is readable (no overflow, proper line breaks)
- Touch targets are at least 44x44px
- Forms are usable (inputs accessible, not hidden behind keyboard)
- Navigation: hamburger menu or drawer opens/closes
- Accordion sections: collapse/expand works on tap
- Carousel/swipes: work with touch events
**Tablet Viewport (768x1024 - iPad):**
- Layout is optimized for tablet (not stretched, not too narrow)
- Multi-column layouts work correctly
- Touch interactions work
**Desktop Viewport (1920x1080 - Large screen):**
- Layout scales correctly
- No horizontal scrolling
- All content is accessible without zooming
---
## 4. Test Categories & Patterns
### 4.1 Happy Path Tests (~30-40% of total: 60-120 tests)
User performs the intended action and succeeds. Example:
```typescript
it('should search flights by arrival city and date', () => {
cy.selectArrivalCity('Москва');
cy.setArrivalDate('15.04.2026');
cy.clickSearchButton();
cy.getFlightResults().should('have.length.greaterThan', 0);
cy.getFirstFlightResult().should('be.visible');
});
```
### 4.2 Edge Case Tests (~20-25% of total: 40-75 tests)
Boundary conditions, extreme inputs, special characters. Examples:
```typescript
it('should handle special characters in flight number', () => {
cy.typeFlightNumber('SU-123@#$%');
cy.shouldShowValidationError('Invalid format');
});
it('should allow searching 1 year in the future', () => {
cy.setDepartureDate(moment().add(365, 'days').format('DD.MM.YYYY'));
cy.clickSearch();
cy.shouldLoadResults();
});
it('should handle empty autocomplete results', () => {
cy.typeArrivalCity('ZZZZZZ');
cy.shouldShowEmptyState('No cities found');
});
```
### 4.3 Error Handling Tests (~15-20% of total: 30-60 tests)
Network failures, invalid responses, timeouts, server errors. Examples:
```typescript
it('should show error message on 500 API failure', () => {
cy.intercept('GET', '**/api/flights/**', { statusCode: 500 });
cy.clickSearch();
cy.getErrorMessage().should('contain', 'Server error');
});
it('should handle network timeout gracefully', () => {
cy.intercept('GET', '**/api/flights/**', { delay: 10000 });
cy.clickSearch();
cy.getLoader().should('be.visible');
cy.getRetryButton().should('be.visible');
});
it('should recover from SignalR connection loss', () => {
cy.window().then(win => {
win.signalRConnection.stop();
});
cy.getConnectionStatusBanner().should('be.visible');
cy.getReconnectButton().click();
cy.getConnectionStatusBanner().should('not.exist');
});
```
### 4.4 State Management Tests (~10-15% of total: 20-45 tests)
Form state persistence, navigation state, localStorage/sessionStorage. Examples:
```typescript
it('should preserve search filters when navigating back', () => {
cy.selectArrivalCity('Москва');
cy.setDate('15.04.2026');
cy.clickFlightResult(0);
cy.goBack();
cy.getArrivalCityInput().should('have.value', 'Москва');
cy.getDateInput().should('have.value', '15.04.2026');
});
it('should remember language selection after page reload', () => {
cy.selectLanguage('en');
cy.reload();
cy.getCurrentLanguage().should('equal', 'en');
});
```
### 4.5 Accessibility & Interaction Tests (~10-15% of total: 20-45 tests)
Keyboard navigation, screen reader support, ARIA attributes, touch interactions. Examples:
```typescript
it('should navigate autocomplete with arrow keys', () => {
cy.typeInAutocomplete('Мос');
cy.get('body').type('{downarrow}');
cy.getFirstAutocompleteOption().should('have.focus');
cy.get('body').type('{enter}');
cy.shouldSelectOption('Москва');
});
it('should be keyboard navigable without mouse', () => {
cy.get('body').type('{tab}{tab}'); // Focus search button
cy.focused().should('have.attr', 'data-testid', 'search-button');
cy.get('body').type('{enter}');
cy.shouldLoadResults();
});
it('should be swipeable on mobile (right swipe)', () => {
cy.viewport('iphone-x');
cy.swipeRight();
cy.getDateInput().should('have.value', moment().subtract(1, 'day').format('DD.MM.YYYY'));
});
```
### 4.6 Localization Tests (~5-10% of total: 10-30 tests)
Language switching, date formats, number formatting. Examples:
```typescript
it('should display correct date format for Russian locale', () => {
cy.selectLanguage('ru');
cy.setDate('15.04.2026');
cy.getDateDisplay().should('contain', '15 апреля 2026');
});
it('should format currency for German locale', () => {
cy.selectLanguage('de');
cy.getFlightPrice().should('contain', '€');
});
it('should format numbers with correct decimal separator', () => {
cy.selectLanguage('de');
cy.getPrice().should('contain', ','); // German decimal separator
cy.selectLanguage('en');
cy.getPrice().should('contain', '.'); // English decimal separator
});
```
---
## 5. Data, Fixtures & Mocking Strategy
### 5.1 Fixture Architecture
```typescript
// cypress/support/fixtures.ts (shared between Angular & React)
export const CITIES = {
arrival: [
{ name: 'Москва', code: 'SVO', lat: 55.7558, lng: 37.62 },
{ name: 'Санкт-Петербург', code: 'LED', lat: 59.8011, lng: 30.2625 },
{ name: 'Анапа', code: 'AAQ', lat: 44.8972, lng: 37.3369 },
{ name: 'Екатеринбург', code: 'SVX', lat: 56.7431, lng: 60.8022 },
// ... 9+ cities total
],
departure: [
{ name: 'Москва', code: 'VKO', lat: 55.5917, lng: 37.2750 },
// ...
]
};
export const MOCK_FLIGHTS = {
arrival: [
{
id: 'SU123',
airline: 'Aeroflot',
number: 'SU 123',
departure: '10:15',
arrival: '11:45',
duration: '1h 30m',
status: 'landed',
gate: 'A5',
terminal: '1'
},
// ... 10+ flights for variety
],
departure: [ /* ... */ ]
};
export const TEST_USERS = {
default: { email: 'test@example.com' },
};
```
### 5.2 Intercept Strategy (Approach C: Real + Mocked)
**Test Suite 1: Mocked API** (deterministic, fast, runs in CI)
```typescript
beforeEach(() => {
cy.intercept('GET', '**/api/flights/v1.1/**', {
statusCode: 200,
body: MOCK_FLIGHTS.arrival
}).as('getFlights');
cy.intercept('GET', '**/api/cities/**', {
statusCode: 200,
body: CITIES.arrival
}).as('getCities');
cy.visit('http://localhost:4200'); // Angular
// OR cy.visit('http://localhost:3000'); // React
});
```
**Test Suite 2: Real API** (integration test, validates actual backend)
```typescript
beforeEach(() => {
// No intercepts — hits real backend
cy.visit('https://test.aeroflot.ru');
});
```
### 5.3 State Reset (Approach A: Full Reset per Test)
```typescript
beforeEach(() => {
// Clear all browser state
cy.clearAllLocalStorage();
cy.clearAllSessionStorage();
cy.clearCookies();
// Close any open WebSocket connections (SignalR)
cy.window().then(win => {
if (win.signalRConnection) {
win.signalRConnection.stop().catch(() => {});
}
});
// Reset to baseline URL
cy.visit('/');
cy.wait(500); // Allow page to fully load
});
afterEach(() => {
// Optional: take screenshot on failure
// (Cypress does this automatically by default)
});
```
### 5.4 Common Cypress Commands
```typescript
// cypress/support/commands.ts
Cypress.Commands.add('selectArrivalCity', (cityName: string) => {
cy.get('[data-testid="arrival-city-input"]').type(cityName);
cy.get(`[data-testid="city-option-${cityName}"]`).click();
});
Cypress.Commands.add('setArrivalDate', (date: string) => {
cy.get('[data-testid="arrival-date-input"]').clear().type(date);
});
Cypress.Commands.add('clickSearchButton', () => {
cy.get('[data-testid="search-button"]').click();
cy.get('[data-testid="loader"]').should('be.visible');
cy.get('[data-testid="loader"]').should('not.exist');
});
Cypress.Commands.add('getFlightResults', () => {
return cy.get('[data-testid="flight-result"]');
});
Cypress.Commands.add('shouldShowValidationError', (message: string) => {
cy.get('[data-testid="error-message"]').should('contain', message);
});
Cypress.Commands.add('swipeRight', () => {
cy.get('body').trigger('touchstart', { touches: [{ clientX: 0, clientY: 100 }] });
cy.get('body').trigger('touchmove', { touches: [{ clientX: 100, clientY: 100 }] });
cy.get('body').trigger('touchend');
});
```
---
## 6. Execution Strategy & Tooling
### 6.1 Cypress Configuration
```typescript
// cypress.config.ts (both Angular & React)
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: process.env.BASE_URL || 'http://localhost:4200',
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 5000,
requestTimeout: 10000,
responseTimeout: 10000,
pageLoadTimeout: 30000,
chromeWebSecurity: false, // for Module Federation
video: true,
videoUploadOnPasses: false,
screenshotOnRunFailure: true,
specPattern: 'cypress/integration/**/*.cy.ts',
supportFile: 'cypress/support/index.ts',
setupNodeEvents(on, config) {
// Example: video recording configuration
return config;
},
},
});
```
### 6.2 NPM Scripts
Add to both `ClientApp/package.json` and `react-app/package.json`:
```json
{
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"cypress:run:all": "cypress run --spec 'cypress/integration/**/*.cy.ts'",
"cypress:run:feature": "cypress run --spec 'cypress/integration/features/*.cy.ts'",
"cypress:run:responsive": "cypress run --spec 'cypress/integration/responsive.cy.ts'",
"cypress:report": "npm run cypress:run && npx mochawesome-report-generator",
"test:e2e": "npm run cypress:run -- --env API_MODE=mocked",
"test:e2e:real": "npm run cypress:run -- --env API_MODE=real BASE_URL=https://test.aeroflot.ru"
}
```
### 6.3 Execution Flow
**Phase 1: Write Angular Tests** (2-3 hours)
- Create all spec files with describe/it structure
- Implement helper functions in `cypress/support/commands.ts`
- Implement Page Object Models in `cypress/support/page-objects/`
- Run incrementally: `npm run cypress:open`
- Validate all tests pass: `npm run cypress:run:all`
**Phase 2: Validate Full Angular Suite** (30 mins)
- Full headless run with video/screenshots: `npm run cypress:run:all`
- Generate HTML report: `npm run cypress:report`
- Fix any flaky tests
**Phase 3: Adapt to React** (2-3 hours)
- Copy spec files to `react-app/cypress/integration/`
- Update selectors in page-objects if React DOM differs
- Update `cypress.config.ts` baseUrl to `:3000`
- Run against React with mocked API: `cd react-app && npm run test:e2e`
- Fix failures (likely selector or navigation changes)
**Phase 4: Run React Suite with Real API** (1-2 hours)
- Run full suite against staging: `npm run test:e2e:real`
- Validate all tests pass
- Fix any integration issues (timing, data, network)
### 6.4 CI/CD Integration
```yaml
# .github/workflows/e2e-tests.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test-angular:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: cd ClientApp && npm ci
- run: npm start > /dev/null 2>&1 &
- run: npx wait-on http://localhost:4200 --timeout 30000
- run: npm run cypress:run:all
- uses: actions/upload-artifact@v3
if: always()
with:
name: cypress-videos-angular
path: ClientApp/cypress/videos
test-react:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: cd react-app && npm ci
- run: npm start > /dev/null 2>&1 &
- run: npx wait-on http://localhost:3000 --timeout 30000
- run: npm run test:e2e
- uses: actions/upload-artifact@v3
if: always()
with:
name: cypress-videos-react
path: react-app/cypress/videos
```
---
## 7. Success Criteria & Validation
### 7.1 Definition of Done
**Angular Tests:**
- ✅ All 200-300 tests written and organized by feature
- ✅ All tests pass against Angular app (100% pass rate)
- ✅ No test takes >10 seconds (performance gate)
- ✅ All test categories represented: happy path, edge case, error handling, state, accessibility, i18n, responsive
- ✅ Code coverage: 80%+ for tested components
**React Tests:**
- ✅ All Angular tests adapted to React (selector/navigation updates only)
- ✅ All tests pass against React app with mocked API (100% pass rate)
- ✅ All tests pass against React app with real API (staging backend)
- ✅ No test takes >10 seconds
- ✅ Feature parity verified: React UI behaves identically to Angular
### 7.2 Metrics to Track
| Metric | Target |
|--------|--------|
| Total Tests (Angular) | 200-300 |
| Total Tests (React) | 200-300 (same) |
| Pass Rate (Angular) | 100% |
| Pass Rate (React, mocked) | 100% |
| Pass Rate (React, real API) | 100% |
| Avg Test Duration | 2-3 seconds |
| Total Suite Time (mocked) | 8-15 minutes |
| Total Suite Time (real API) | 10-20 minutes |
| Code Coverage (tested components) | 80%+ |
| Flaky Test Count | 0 |
### 7.3 Stopping Condition
Work continues until:
- ✅ All 200-300 tests pass on Angular
- ✅ All 200-300 tests pass on React (mocked API)
- ✅ All 200-300 tests pass on React (real API)
- ✅ No test takes >10 seconds
- ✅ Re-running test suite 3x produces consistent results (no flaky tests)
- ✅ Angular and React behavior is identical for all covered features
---
## 8. Implementation Notes
### 8.1 DOM Structure Assumptions
Tests assume the following data-testid attributes exist on UI elements (both Angular and React must implement these):
```
Online Board:
- data-testid="arrival-city-input"
- data-testid="arrival-date-input"
- data-testid="search-button"
- data-testid="flight-result"
- data-testid="loader"
- data-testid="error-message"
- data-testid="flight-details-modal"
Schedule:
- data-testid="origin-input"
- data-testid="destination-input"
- data-testid="date-range-picker"
- data-testid="passenger-count"
- data-testid="search-button"
And so on...
```
If either implementation uses different selectors, Page Object Models will be updated to translate.
### 8.2 Test Data Lifecycle
- **Setup:** Full state reset before each test (localStorage, cookies, connections)
- **Execution:** Test runs against isolated mocked data
- **Teardown:** Browser state cleared automatically
No test data persists between tests.
### 8.3 Handling Flaky Tests
If a test is flaky:
1. Add explicit waits for async operations
2. Retry the specific assertion (Cypress built-in)
3. Check for race conditions in test logic
4. If unsolvable, mark as skipped with comment explaining issue
### 8.4 Performance Constraints
- Each test: <10 seconds (includes setup, execution, teardown)
- Full suite: <30 minutes (8-15 min for mocked, 10-20 min for real API)
- If a test exceeds 10 seconds, it's split or optimized
---
## 9. Risks & Mitigation
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|-----------|
| React selectors differ from Angular | High | Medium | Page Object Model abstraction; update POM for React |
| Flaky network-dependent tests | Medium | High | Use mocked API for primary suite; real API as secondary |
| Test explosion (200-300 is large) | Medium | High | Phased execution; monitor suite time; parallelize if needed |
| Timing issues (async operations) | Medium | Medium | Explicit waits, retry logic, proper Cypress commands |
| Mobile tests on CI | Medium | Low | Use Cypress viewport, skip on CI if needed, test locally |
---
## 10. Timeline & Ownership
| Phase | Estimate | Owner |
|-------|----------|-------|
| 1. Write Angular Tests | 2-3 hours | Claude Code |
| 2. Validate Angular | 30 mins | Claude Code |
| 3. Adapt to React | 2-3 hours | Claude Code |
| 4. Validate React (mocked) | 1 hour | Claude Code |
| 5. Validate React (real API) | 1-2 hours | Claude Code |
| **Total** | **7-10 hours** | |
Work continues until **all tests pass** on both versions.
---
## Appendix: Reference
### A.1 Cypress Best Practices Used
- ✅ Page Object Model for selector abstraction
- ✅ Custom commands for common actions
- ✅ Explicit waits over implicit
- ✅ Data attributes (data-testid) for element selection
- ✅ Full state reset between tests
- ✅ Feature-based organization
- ✅ Mocking + real API testing
### A.2 Languages Supported (i18n)
1. Russian (ru)
2. English (en)
3. Spanish (es)
4. French (fr)
5. Italian (it)
6. Japanese (ja)
7. Korean (ko)
8. Chinese (zh)
9. German (de)
All 9 languages must have passing tests.
### A.3 Key Features Tested
1. Online Board (departure/arrival tabs, search, filters, flight details)
2. Schedule (search page, flight details, filters, sorting)
3. Flights Map (map rendering, markers, destination list, interactions)
4. Popular Requests (widget load, navigation, fallback)
5. Internationalization (language switching, formatting, persistence)
6. Error States (network failures, validation, loading states, recovery)
7. Responsive Design (mobile, tablet, desktop viewports)
File diff suppressed because it is too large Load Diff
-180
View File
@@ -1,180 +0,0 @@
// ESLint v9 flat config. Mirrors the rule set from
// docs/superpowers/plans/2026-04-14-phase-1a1-skeleton.md (Task 4).
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import unusedImports from "eslint-plugin-unused-imports";
import boundaries from "eslint-plugin-boundaries";
export default [
{
ignores: [
"dist/**",
"node_modules/**",
"ClientApp/**",
"wwwroot/**",
"**/*.cjs",
"pnpm-lock.yaml",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaVersion: 2023,
sourceType: "module",
project: "./tsconfig.json",
ecmaFeatures: { jsx: true },
},
},
plugins: {
"unused-imports": unusedImports,
},
rules: {
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports" },
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "warn",
"no-console": ["warn", { allow: ["warn", "error"] }],
},
},
{
files: ["src/**/*.{ts,tsx}"],
plugins: {
boundaries,
},
settings: {
"import/resolver": {
node: {
extensions: [".ts", ".tsx", ".js", ".jsx"],
},
},
"boundaries/elements": [
{ type: "routes", pattern: "src/routes/*" },
{ type: "mf", pattern: "src/mf/*" },
{ type: "features", pattern: "src/features/*", capture: ["feature"] },
{ type: "ui", pattern: "src/ui/*" },
{ type: "shared", pattern: "src/shared/*" },
{ type: "observability", pattern: "src/observability/*" },
{ type: "i18n", pattern: "src/i18n/*" },
{ type: "env", pattern: "src/env/*" },
],
},
rules: {
// Design spec §1.2 layered dependency direction:
// features/ cannot import routes/ or mf/
// ui/ cannot import features/
// shared/ cannot import features/, routes/, mf/, observability/
// observability/ cannot import features/, routes/, mf/
"boundaries/element-types": [
"error",
{
default: "allow",
rules: [
{
from: "features",
disallow: ["routes", "mf"],
message: "Features must not import from routes/ or mf/. Use the HostContract or a shared module instead.",
},
{
from: "ui",
disallow: ["features", "routes", "mf"],
message: "UI layer must not import from features/, routes/, or mf/. UI is consumed by features, not the other way around.",
},
{
from: "shared",
disallow: ["features", "routes", "mf", "observability"],
message: "Shared modules must not import from features/, routes/, mf/, or observability/.",
},
{
from: "observability",
disallow: ["features", "routes", "mf"],
message: "Observability modules must not import from features/, routes/, or mf/.",
},
],
},
],
},
},
// --- Restricted imports (master plan §1A-3) ---
// OTel SDK internals + react-i18next: merged into one block so ESLint 9
// flat config doesn't override one rule with the other.
// otel.ts is allowed to import OTel SDK; provider.tsx is allowed to import react-i18next.
// Neither file needs the other's exemption, so combining ignores is safe.
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/observability/metrics/otel.ts", "src/observability/metrics/otel.test.ts", "src/i18n/provider.tsx"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@opentelemetry/sdk-metrics",
message: "Import from @opentelemetry/api or use getMeter() from @/observability/metrics/otel instead. Direct SDK access is restricted to src/observability/metrics/otel.ts.",
},
{
name: "@opentelemetry/sdk-node",
message: "Import from @opentelemetry/api or use getMeter()/getTracer() from @/observability/metrics/otel instead. Direct SDK access is restricted to src/observability/metrics/otel.ts.",
},
{
name: "react-i18next",
message: "Import useTranslation from @/i18n/provider instead. Direct react-i18next imports are restricted to src/i18n/provider.tsx.",
},
],
},
],
},
},
// @microsoft/signalr: forbidden in files that run during SSR.
// SSR-bundle = routes/ and server/ directories. Features + shared/hooks are client-side.
{
files: ["src/routes/**/*.{ts,tsx}", "src/server/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@microsoft/signalr",
message: "SignalR must not be imported in SSR-bundle files (routes/, server/). Use dynamic import in a useEffect or a client-only wrapper.",
},
],
},
],
},
},
// window.localStorage / window.sessionStorage: only src/shared/storage.ts
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/shared/storage.ts", "src/shared/storage.test.ts"],
rules: {
"no-restricted-globals": [
"error",
{
name: "localStorage",
message: "Use the storage module from @/shared/storage instead. Direct localStorage access is restricted to src/shared/storage.ts.",
},
{
name: "sessionStorage",
message: "Use the storage module from @/shared/storage instead. Direct sessionStorage access is restricted to src/shared/storage.ts.",
},
],
},
},
];
-13
View File
@@ -1,13 +0,0 @@
import { appTools, defineConfig } from "@modern-js/app-tools";
import { moduleFederationPlugin } from "@module-federation/modern-js";
const buildTarget = process.env["BUILD_TARGET"];
const isRemote = buildTarget === "remote";
export default defineConfig({
plugins: [appTools({ bundler: "rspack" }), moduleFederationPlugin()],
server: isRemote ? {} : { ssr: { mode: "stream" } },
output: {
distPath: { root: isRemote ? "dist/remote" : "dist/standalone" },
},
});
-15
View File
@@ -1,15 +0,0 @@
import { createModuleFederationConfig } from "@module-federation/modern-js";
export default createModuleFederationConfig({
name: "aeroflot_flights",
exposes: {
"./OnlineBoard": "./src/mf/expose/OnlineBoard.tsx",
"./Schedule": "./src/mf/expose/Schedule.tsx",
"./FlightsMap": "./src/mf/expose/FlightsMap.tsx",
"./PopularRequests": "./src/mf/expose/PopularRequests.tsx",
},
shared: {
react: { singleton: true, requiredVersion: "^18.2.0" },
"react-dom": { singleton: true, requiredVersion: "^18.2.0" },
},
});
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Aeroflot.Flights.Web",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
-70
View File
@@ -1,70 +0,0 @@
{
"name": "@aeroflot/flights-web",
"version": "0.0.0",
"private": true,
"description": "Aeroflot Flights — Modern.js + MF 2.0 React remote component (Phase 1 foundation)",
"license": "UNLICENSED",
"type": "module",
"engines": {
"node": ">=24.2.0",
"pnpm": ">=9.0.0"
},
"packageManager": "pnpm@9.15.3",
"scripts": {
"dev": "modern dev",
"build:standalone": "BUILD_TARGET=standalone modern build",
"build:remote": "BUILD_TARGET=remote modern build",
"build:both": "pnpm build:standalone && pnpm build:remote",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint \"src/**/*.{ts,tsx}\" --max-warnings 0",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@microsoft/signalr": "^10.0.0",
"@modern-js/app-tools": "2.70.8",
"@modern-js/runtime": "^3.1.3",
"@module-federation/enhanced": "2.3.2",
"@module-federation/modern-js": "2.3.2",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/sdk-node": "^0.214.0",
"i18next": "^23.0.0",
"i18next-icu": "^2.0.0",
"i18next-resources-to-backend": "^1.0.0",
"intl-messageformat": "^10.0.0",
"leaflet": "^1.9.4",
"lru-cache": "^10.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^15.0.0",
"schema-dts": "^2.0.0",
"web-vitals": "^5.2.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/jest-dom": "^6",
"@testing-library/react": "^16.3.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/ui": "^3.0.0",
"eslint": "^9.0.0",
"eslint-plugin-boundaries": "^5.0.0",
"eslint-plugin-unused-imports": "^4.0.0",
"fast-check": "^4.6.0",
"jsdom": "^29.0.2",
"react-test-renderer": "^19.2.5",
"typescript": "^5.5.0",
"typescript-eslint": "^8.58.2",
"vite": "^6.0.0",
"vitest": "^3.0.0"
}
}
-13760
View File
File diff suppressed because it is too large Load Diff
-91
View File
@@ -1,91 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
describe("getEnv", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
// Clear env to a clean slate, then set a minimum valid shape.
for (const key of Object.keys(process.env)) {
if (key.startsWith("VITEST_")) continue;
delete process.env[key];
}
process.env["NODE_ENV"] = "testing";
process.env["BUILD_TARGET"] = "standalone";
process.env["PROD_ORIGIN"] = "https://flights.aeroflot.ru";
process.env["API_BASE_URL"] = "https://platform.aeroflot.ru";
process.env["SIGNALR_HUB_URL"] = "wss://platform.aeroflot.ru/hub";
process.env["ANALYTICS_METRICA"] = "true";
process.env["ANALYTICS_CTM"] = "false";
process.env["ANALYTICS_VARIOCUBE"] = "false";
process.env["ANALYTICS_DYNATRACE"] = "true";
process.env["VERSION"] = "abc1234";
});
afterEach(async () => {
process.env = { ...originalEnv };
// Reset the module cache so getEnv re-reads the updated env.
const mod = await import("./index.js");
mod.__resetEnvCacheForTests();
});
it("returns a typed Env object for valid input", async () => {
const { getEnv } = await import("./index.js");
const env = getEnv();
expect(env.NODE_ENV).toBe("testing");
expect(env.BUILD_TARGET).toBe("standalone");
expect(env.PROD_ORIGIN).toBe("https://flights.aeroflot.ru");
expect(env.API_BASE_URL).toBe("https://platform.aeroflot.ru");
expect(env.SIGNALR_HUB_URL).toBe("wss://platform.aeroflot.ru/hub");
expect(env.ANALYTICS_ENABLED).toEqual({
metrica: true,
ctm: false,
variocube: false,
dynatrace: true,
});
expect(env.VERSION).toBe("abc1234");
expect(env.OTEL_EXPORTER_OTLP_ENDPOINT).toBeUndefined();
});
it("caches the result across calls (same object identity)", async () => {
const { getEnv } = await import("./index.js");
const a = getEnv();
const b = getEnv();
expect(a).toBe(b);
});
it("throws a readable error when a required field is missing", async () => {
delete process.env["API_BASE_URL"];
const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
__resetEnvCacheForTests();
expect(() => getEnv()).toThrow(/API_BASE_URL/);
});
it("throws when NODE_ENV is not one of the allowed values", async () => {
process.env["NODE_ENV"] = "banana";
const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
__resetEnvCacheForTests();
expect(() => getEnv()).toThrow(/NODE_ENV/);
});
it("throws when BUILD_TARGET is neither standalone nor remote", async () => {
process.env["BUILD_TARGET"] = "hybrid";
const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
__resetEnvCacheForTests();
expect(() => getEnv()).toThrow(/BUILD_TARGET/);
});
it("accepts optional OTEL_EXPORTER_OTLP_ENDPOINT when provided", async () => {
process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "https://otel.example/v1/traces";
const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
__resetEnvCacheForTests();
const env = getEnv();
expect(env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://otel.example/v1/traces");
});
it("rejects OTEL_EXPORTER_OTLP_ENDPOINT that is not a URL", async () => {
process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "not-a-url";
const { getEnv, __resetEnvCacheForTests } = await import("./index.js");
__resetEnvCacheForTests();
expect(() => getEnv()).toThrow(/OTEL_EXPORTER_OTLP_ENDPOINT/);
});
});
-89
View File
@@ -1,89 +0,0 @@
import { z } from "zod";
import type { AnalyticsProviders } from "@/observability/analytics/types";
const boolish = z
.enum(["true", "false", "1", "0"])
.transform((v) => v === "true" || v === "1");
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "testing", "staging", "production"]),
BUILD_TARGET: z.enum(["standalone", "remote"]),
PROD_ORIGIN: z.string().url(),
API_BASE_URL: z.string().url(),
SIGNALR_HUB_URL: z.string().url(),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
LOGS_ENDPOINT: z.string().url().optional(),
ANALYTICS_METRICA: boolish.default("false"),
ANALYTICS_CTM: boolish.default("false"),
ANALYTICS_VARIOCUBE: boolish.default("false"),
ANALYTICS_DYNATRACE: boolish.default("false"),
FEATURE_FLIGHTS_MAP: boolish.default("false"),
VERSION: z.string().min(1),
});
type RawEnv = z.infer<typeof EnvSchema>;
export interface Env {
NODE_ENV: RawEnv["NODE_ENV"];
BUILD_TARGET: RawEnv["BUILD_TARGET"];
PROD_ORIGIN: string;
API_BASE_URL: string;
SIGNALR_HUB_URL: string;
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
OTEL_EXPORTER_OTLP_HEADERS?: string;
LOGS_ENDPOINT?: string;
ANALYTICS_ENABLED: AnalyticsProviders;
FEATURE_FLIGHTS_MAP: boolean;
VERSION: string;
}
let cached: Env | undefined;
export function getEnv(): Env {
if (cached) return cached;
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
const details = parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
throw new Error(`Invalid environment configuration: ${details}`);
}
const raw = parsed.data;
const result: Env = {
NODE_ENV: raw.NODE_ENV,
BUILD_TARGET: raw.BUILD_TARGET,
PROD_ORIGIN: raw.PROD_ORIGIN,
API_BASE_URL: raw.API_BASE_URL,
SIGNALR_HUB_URL: raw.SIGNALR_HUB_URL,
ANALYTICS_ENABLED: {
metrica: raw.ANALYTICS_METRICA,
ctm: raw.ANALYTICS_CTM,
variocube: raw.ANALYTICS_VARIOCUBE,
dynatrace: raw.ANALYTICS_DYNATRACE,
},
FEATURE_FLIGHTS_MAP: raw.FEATURE_FLIGHTS_MAP,
VERSION: raw.VERSION,
};
if (raw.OTEL_EXPORTER_OTLP_ENDPOINT !== undefined) {
result.OTEL_EXPORTER_OTLP_ENDPOINT = raw.OTEL_EXPORTER_OTLP_ENDPOINT;
}
if (raw.OTEL_EXPORTER_OTLP_HEADERS !== undefined) {
result.OTEL_EXPORTER_OTLP_HEADERS = raw.OTEL_EXPORTER_OTLP_HEADERS;
}
if (raw.LOGS_ENDPOINT !== undefined) {
result.LOGS_ENDPOINT = raw.LOGS_ENDPOINT;
}
cached = result;
return result;
}
/**
* Test-only: resets the module-level cache so tests can mutate process.env
* and re-read. Do NOT call this from production code.
*/
export function __resetEnvCacheForTests(): void {
cached = undefined;
}
-216
View File
@@ -1,216 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { ApiClient } from "@/shared/api/client";
import { searchDestinations, getFlightsMapCalendar } from "./api";
import type {
FlightsMapSearchParams,
IDestinationsResponse,
IFlightsMapDaysResponse,
} from "./types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createMockClient(responseBody: unknown, status = 200) {
const mockFetch = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify(responseBody), {
status,
headers: { "Content-Type": "application/json" },
}),
);
const client = new ApiClient({
baseUrl: "https://api.test.com",
locale: "ru",
fetchImpl: mockFetch,
retry: { maxRetries: 0 },
});
return { client, mockFetch };
}
function extractUrl(mockFetch: ReturnType<typeof vi.fn>): URL {
const call = mockFetch.mock.calls[0] as [string, ...unknown[]];
return new URL(call[0]);
}
const DESTINATIONS_RESPONSE: IDestinationsResponse = {
data: {
routes: [
{ route: ["SVO", "LED"], isDirect: true },
{ route: ["SVO", "DME", "LED"], isDirect: false },
],
},
};
const DAYS_RESPONSE: IFlightsMapDaysResponse = {
days: "01101",
};
// ---------------------------------------------------------------------------
// searchDestinations
// ---------------------------------------------------------------------------
describe("searchDestinations", () => {
it("sends GET to destinations/1 with query params", async () => {
const { client, mockFetch } = createMockClient(DESTINATIONS_RESPONSE);
const params: FlightsMapSearchParams = {
departure: "SVO",
arrival: "LED",
dateFrom: "20250601",
dateTo: "20251201",
};
await searchDestinations(client, params);
const url = extractUrl(mockFetch);
expect(url.pathname).toBe("/destinations/1");
expect(url.searchParams.get("departure")).toBe("SVO");
expect(url.searchParams.get("arrival")).toBe("LED");
expect(url.searchParams.get("dateFrom")).toBe("20250601");
expect(url.searchParams.get("dateTo")).toBe("20251201");
expect(url.searchParams.get("connections")).toBe("0");
});
it("omits arrival param when not provided", async () => {
const { client, mockFetch } = createMockClient(DESTINATIONS_RESPONSE);
await searchDestinations(client, {
departure: "SVO",
dateFrom: "20250601",
dateTo: "20251201",
});
const url = extractUrl(mockFetch);
expect(url.searchParams.has("arrival")).toBe(false);
});
it("sends connections param when specified", async () => {
const { client, mockFetch } = createMockClient(DESTINATIONS_RESPONSE);
await searchDestinations(client, {
departure: "SVO",
arrival: "LED",
dateFrom: "20250601",
dateTo: "20251201",
connections: 1,
});
const url = extractUrl(mockFetch);
expect(url.searchParams.get("connections")).toBe("1");
});
it("returns the deserialized response", async () => {
const { client } = createMockClient(DESTINATIONS_RESPONSE);
const result = await searchDestinations(client, {
departure: "SVO",
arrival: "LED",
dateFrom: "20250601",
dateTo: "20251201",
});
expect(result).toEqual(DESTINATIONS_RESPONSE);
});
it("throws on server error", async () => {
const { client } = createMockClient({ error: "internal" }, 500);
await expect(
searchDestinations(client, {
departure: "SVO",
dateFrom: "20250601",
dateTo: "20251201",
}),
).rejects.toThrow("HTTP 500");
});
});
// ---------------------------------------------------------------------------
// getFlightsMapCalendar
// ---------------------------------------------------------------------------
describe("getFlightsMapCalendar", () => {
it("builds correct path for direct route", async () => {
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
await getFlightsMapCalendar(client, {
date: "20250601",
departure: "SVO",
arrival: "LED",
connections: false,
});
const url = extractUrl(mockFetch);
expect(url.pathname).toBe("/days/20250601/200/route/SVO-LED/flights-map/v1");
});
it("builds correct path for connecting route", async () => {
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
await getFlightsMapCalendar(client, {
date: "20250601",
departure: "SVO",
arrival: "LED",
connections: true,
});
const url = extractUrl(mockFetch);
expect(url.pathname).toBe(
"/days/20250601/200/connections/SVO-LED-1/flights-map/v1",
);
});
it("builds correct path for departure-only (spider mode)", async () => {
const { client, mockFetch } = createMockClient(DAYS_RESPONSE);
await getFlightsMapCalendar(client, {
date: "20250601",
departure: "SVO",
connections: false,
});
const url = extractUrl(mockFetch);
expect(url.pathname).toBe("/days/20250601/200/departure/SVO/flights-map/v1");
});
it("parses binary days string into available date strings", async () => {
const { client } = createMockClient({ days: "10110" });
const result = await getFlightsMapCalendar(client, {
date: "20250601",
departure: "SVO",
arrival: "LED",
connections: false,
});
expect(result).toEqual(["20250601", "20250603", "20250604"]);
});
it("returns empty array for empty days string", async () => {
const { client } = createMockClient({ days: "" });
const result = await getFlightsMapCalendar(client, {
date: "20250601",
departure: "SVO",
arrival: "LED",
connections: false,
});
expect(result).toEqual([]);
});
it("throws on server error", async () => {
const { client } = createMockClient({ error: "internal" }, 500);
await expect(
getFlightsMapCalendar(client, {
date: "20250601",
departure: "SVO",
arrival: "LED",
connections: false,
}),
).rejects.toThrow("HTTP 500");
});
});

Some files were not shown because too many files have changed in this diff Show More