diff --git a/.gitea/workflows/ci-deploy.yml b/.gitea/workflows/ci-deploy.yml new file mode 100644 index 00000000..166d926b --- /dev/null +++ b/.gitea/workflows/ci-deploy.yml @@ -0,0 +1,130 @@ +name: ci-deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-deploy-test: + runs-on: pve-201 + timeout-minutes: 30 + env: + MAP_TILE_URL: ${{ secrets.MAP_TILE_URL || '/map/api/tile/{z}/{x}/{y}.jpeg' }} + API_BASE_URL: ${{ secrets.API_BASE_URL || '/api' }} + BASIC_AUTH_USER: ${{ secrets.BASIC_AUTH_USER }} + BASIC_AUTH_PASS: ${{ secrets.BASIC_AUTH_PASS }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + FLIGHTS_WEB_PORT: '8081' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Notify start + if: ${{ env.TELEGRAM_BOT_TOKEN != '' }} + run: scripts/ci/notify-telegram.sh start ci-deploy + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Restore pnpm cache + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm- + + - name: Install dependencies + id: deps + run: pnpm install --frozen-lockfile + + - name: Typecheck + id: typecheck + run: pnpm typecheck + + - name: Lint + id: lint + run: pnpm lint + + - name: Unit tests + id: unit + run: pnpm test + + - name: CI script tests + id: citest + run: pnpm test:ci + + - name: Build SSR image + id: docker_build + run: | + docker build -f Dockerfile.react \ + --build-arg "MAP_TILE_URL=${MAP_TILE_URL}" \ + --build-arg "API_BASE_URL=${API_BASE_URL}" \ + -t "flights-web:${GITHUB_SHA:0:7}" \ + . + + - name: Render htpasswd + reload nginx + id: htpasswd + run: scripts/ci/install-htpasswd.sh + + - name: Swap container + id: swap + run: scripts/ci/deploy-container.sh swap + + - name: Wait for health + id: health + env: + BASIC_AUTH_USER: ${{ secrets.BASIC_AUTH_USER }} + BASIC_AUTH_PASS: ${{ secrets.BASIC_AUTH_PASS }} + run: scripts/ci/wait-for-url.sh https://ui-dashboard.gnerim.ru/ 30 2 + + - name: Run Playwright e2e + id: e2e + env: + BASE_URL: http://127.0.0.1:8081 + run: pnpm test:e2e + + - name: Rollback on failure (post-deploy steps) + if: failure() && (steps.swap.outcome == 'failure' || steps.health.outcome == 'failure' || steps.e2e.outcome == 'failure') + id: rollback + run: scripts/ci/deploy-container.sh rollback + + - name: Capture container logs (on failure) + if: failure() + run: docker logs flights-web --tail 500 > container.log 2>&1 || true + + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ci-deploy-failure-${{ github.run_id }} + path: | + container.log + playwright-report/ + retention-days: 7 + + - name: Prune old images + if: success() + run: | + docker images flights-web --format '{{.Tag}} {{.ID}}' \ + | grep -vE '^(current|previous)\b' \ + | tail -n +6 \ + | awk '{print $2}' \ + | xargs -r docker rmi 2>/dev/null || true + + - name: Notify (success) + if: success() && env.TELEGRAM_BOT_TOKEN != '' + run: scripts/ci/notify-telegram.sh ok ci-deploy + + - name: Notify (failure) + if: failure() && env.TELEGRAM_BOT_TOKEN != '' + run: scripts/ci/notify-telegram.sh fail ci-deploy "see run for details" container.log diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 00000000..5679e9f2 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,200 @@ +name: release + +on: + workflow_dispatch: + push: + tags: + - 'release-*' + +jobs: + release: + runs-on: pve-201 + timeout-minutes: 60 + env: + GITLAB_PAT: ${{ secrets.GITLAB_PAT }} + GITLAB_PROJECT_ID: ${{ secrets.GITLAB_PROJECT_ID }} + GITLAB_HOST: 'https://teamscore.gitlab.yandexcloud.net' + GITLAB_PROJECT_PATH: 'aeroflot2/flights-front' + JENKINS_BASE_URL: 'http://jenkins.yc.devwebzavod.ru:8080' + JENKINS_JOB_PATH: '/job/Aeroflot2/job/Flights-Front-Dev' + JENKINS_USER: ${{ secrets.JENKINS_USER }} + JENKINS_API_TOKEN: ${{ secrets.JENKINS_API_TOKEN }} + JENKINS_TRIGGER_TOKEN: ${{ secrets.JENKINS_TRIGGER_TOKEN }} + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + + steps: + - name: Checkout (full history + tags) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Notify start + if: ${{ env.TELEGRAM_BOT_TOKEN != '' }} + run: scripts/ci/notify-telegram.sh start release + + - name: Verify ci-deploy is green for this SHA + id: gate + run: | + API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${GITHUB_SHA}" + # Gitea Actions API is similar to GitHub's; this query may differ slightly per Gitea version. + # If the endpoint isn't available, fall back to a last-3-runs check via the workflows endpoint. + resp=$(curl -fsS -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "$API" || echo '{"workflow_runs":[]}') + ok=$(echo "$resp" | jq -r --arg name "ci-deploy" ' + .workflow_runs[] + | select(.name == $name) + | .conclusion + ' | head -1) + if [ "$ok" != "success" ]; then + echo "fatal: ci-deploy is not green for ${GITHUB_SHA} (got: '${ok:-none}')" + exit 1 + fi + echo "ci-deploy green for ${GITHUB_SHA}" + + - name: Setup Node + pnpm + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + - uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Paranoid re-run — typecheck + lint + unit + id: paranoid + run: | + pnpm typecheck + pnpm lint + pnpm test + pnpm test:ci + + - name: Clone GitLab target + id: clone + env: + GITLAB_PAT: ${{ secrets.GITLAB_PAT }} + run: | + rm -rf /tmp/flights-front + git clone "https://oauth2:${GITLAB_PAT}@teamscore.gitlab.yandexcloud.net/aeroflot2/flights-front.git" /tmp/flights-front + mkdir -p /tmp/flights-front/Aeroflot.Flights.Front + + - name: Sync to GitLab clone + id: sync + run: scripts/ci/sync-to-gitlab.sh /tmp/flights-front/Aeroflot.Flights.Front + + - name: Commit on auto branch + id: commit + run: | + cd /tmp/flights-front + git config user.email "ci@gnerim.ru" + git config user.name "gnerim CI" + BRANCH="auto/sync-${GITHUB_SHA:0:7}" + git checkout -b "$BRANCH" + git add -A + if git diff --cached --quiet; then + echo "nothing to sync" + echo "skip_remaining=1" >> "$GITHUB_OUTPUT" + exit 0 + fi + git commit -m "auto: sync from gitea ${GITHUB_SHA:0:7}" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Push branch + id: push + if: steps.commit.outputs.skip_remaining != '1' + run: | + cd /tmp/flights-front + git push -u origin "${{ steps.commit.outputs.branch }}" + + - name: Open MR + id: mr_open + if: steps.commit.outputs.skip_remaining != '1' + run: | + BRANCH="${{ steps.commit.outputs.branch }}" + TITLE="auto: sync from gitea ${GITHUB_SHA:0:7}" + BODY="Auto-sync from gitea run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + resp=$(curl -fsS -X POST \ + -H "PRIVATE-TOKEN: ${GITLAB_PAT}" \ + -H "Content-Type: application/json" \ + -d "$(jq -nc --arg sb "$BRANCH" --arg t "$TITLE" --arg d "$BODY" '{source_branch:$sb, target_branch:"main", title:$t, description:$d, remove_source_branch:true, squash:true}')" \ + "${GITLAB_HOST}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests") + IID=$(echo "$resp" | jq -r '.iid') + [ "$IID" != "null" ] || { echo "fatal: MR open failed: $resp" >&2; exit 1; } + echo "iid=$IID" >> "$GITHUB_OUTPUT" + echo "url=$(echo "$resp" | jq -r '.web_url')" >> "$GITHUB_OUTPUT" + + - name: Approve MR + id: mr_approve + if: steps.commit.outputs.skip_remaining != '1' + run: | + curl -fsS -X POST \ + -H "PRIVATE-TOKEN: ${GITLAB_PAT}" \ + "${GITLAB_HOST}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests/${{ steps.mr_open.outputs.iid }}/approve" \ + >/dev/null || { + echo "fatal: MR approve failed — verify 'Prevent approval by author' is unchecked" + exit 1 + } + + - name: Merge MR + id: mr_merge + if: steps.commit.outputs.skip_remaining != '1' + run: | + curl -fsS -X PUT \ + -H "PRIVATE-TOKEN: ${GITLAB_PAT}" \ + -H "Content-Type: application/json" \ + -d '{"merge_when_pipeline_succeeds":false,"should_remove_source_branch":true,"squash":true}' \ + "${GITLAB_HOST}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests/${{ steps.mr_open.outputs.iid }}/merge" \ + >/dev/null + + - name: Cleanup MR + branch on failure (B:9-11 only) + if: failure() && (steps.mr_open.outcome == 'failure' || steps.mr_approve.outcome == 'failure' || steps.mr_merge.outcome == 'failure') + run: | + IID="${{ steps.mr_open.outputs.iid }}" + BRANCH="${{ steps.commit.outputs.branch }}" + if [ -n "$IID" ] && [ "$IID" != "null" ]; then + curl -fsS -X PUT \ + -H "PRIVATE-TOKEN: ${GITLAB_PAT}" \ + -H "Content-Type: application/json" \ + -d '{"state_event":"close"}' \ + "${GITLAB_HOST}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests/${IID}" \ + >/dev/null || true + fi + if [ -n "$BRANCH" ]; then + curl -fsS -X DELETE \ + -H "PRIVATE-TOKEN: ${GITLAB_PAT}" \ + "${GITLAB_HOST}/api/v4/projects/${GITLAB_PROJECT_ID}/repository/branches/$(printf '%s' "$BRANCH" | sed 's|/|%2F|g')" \ + >/dev/null || true + fi + + - name: Trigger + wait for Jenkins + id: jenkins + if: steps.commit.outputs.skip_remaining != '1' + run: scripts/ci/jenkins-trigger-and-wait.sh + + - name: Wait for customer URL to update + id: wait_customer + if: steps.commit.outputs.skip_remaining != '1' + run: scripts/ci/wait-for-url.sh http://flights-ui.devwebzavod.ru/ru-ru/onlineboard 60 5 + + - name: Run Playwright e2e against customer URL + id: e2e_customer + if: steps.commit.outputs.skip_remaining != '1' + env: + BASE_URL: http://flights-ui.devwebzavod.ru + run: pnpm test:e2e + + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: release-failure-${{ github.run_id }} + path: | + playwright-report/ + retention-days: 7 + + - name: Notify (success) + if: success() && env.TELEGRAM_BOT_TOKEN != '' + run: scripts/ci/notify-telegram.sh ok release "MR ${{ steps.mr_open.outputs.url }}" + + - name: Notify (failure) + if: failure() && env.TELEGRAM_BOT_TOKEN != '' + run: scripts/ci/notify-telegram.sh fail release "see Gitea run" diff --git a/Makefile b/Makefile index f50c214f..63c4252a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev dev-full stop status logs build build-remote build-both test test-coverage lint typecheck e2e check clean install sync +.PHONY: help dev dev-full stop status logs build build-remote build-both test test-coverage lint typecheck e2e check clean install sync test-ci help: @echo "Aeroflot.Flights.Web — Available commands:" @@ -22,6 +22,7 @@ help: @echo " make lint - Lint code (ESLint)" @echo " make typecheck - Type check (TypeScript)" @echo " make check - Run typecheck + lint + test" + @echo " make test-ci - Run CI script unit tests (bash)" @echo "" @echo " E2E Testing:" @echo " make e2e - Run Playwright E2E tests" @@ -117,3 +118,7 @@ sync: # Setup install: $(PNPM) install + +# CI-script unit tests +test-ci: + $(PNPM) test:ci diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 00000000..a1501947 --- /dev/null +++ b/deployment/README.md @@ -0,0 +1,199 @@ +# pve-201 Deployment Runbook + +This is the bootstrap procedure for hosting `https://ui-dashboard.gnerim.ru/` on pve-201, plus rehearsal recipes for the CI/CD pipeline failure paths. The full design rationale lives in `docs/superpowers/specs/2026-04-25-cicd-pipeline-design.md`. + +## One-time setup + +### 1. Routing pve-201 → TIM API (via webzavod) + +**On webzavod (192.168.88.58)** — verify IP forwarding and MASQUERADE: + +```bash +sysctl net.ipv4.ip_forward # expect: 1 +sudo iptables -t nat -L POSTROUTING -nv | grep ppp0 # expect: MASQUERADE rule +``` + +If missing: + +```bash +echo 'net.ipv4.ip_forward=1' | sudo tee -a /etc/sysctl.conf +sudo sysctl -p +sudo iptables -t nat -A POSTROUTING -o ppp0 -j MASQUERADE +sudo apt install iptables-persistent +sudo netfilter-persistent save +``` + +**On pve-201** — add a persistent static route to TIM via webzavod: + +```yaml +# /etc/netplan/01-routes.yaml — adjust NIC name as needed +network: + version: 2 + ethernets: + : # replace with actual NIC name from `ip link show` + routes: + - to: 172.18.0.0/16 + via: 192.168.88.58 +``` + +```bash +sudo netplan apply +``` + +**On pve-201** — pin TIM hostnames to reachable A records (TIM DNS returns duplicate As, one of which is dead): + +```bash +echo '172.18.0.121 flights.test.aeroflot.ru' | sudo tee -a /etc/hosts +``` + +**Smoke test:** + +```bash +curl -v https://flights.test.aeroflot.ru/swagger/ # expect: 401 in <300ms +``` + +If this fails, fix routing/DNS before proceeding — nothing else will work. + +### 2. nginx vhost + +```bash +cd /path/to/Aeroflot.Flights.Web # repo root, e.g. ~/repos/Aeroflot.Flights.Web +sudo cp deployment/nginx/ui-dashboard.gnerim.ru.conf /etc/nginx/sites-available/ +sudo ln -s /etc/nginx/sites-available/ui-dashboard.gnerim.ru.conf /etc/nginx/sites-enabled/ +sudo mkdir -p /etc/nginx/htpasswd +sudo nginx -t +sudo systemctl reload nginx +``` + +The `htpasswd` file is created by `scripts/ci/install-htpasswd.sh` on first deploy. + +### 3. Gitea runner setup + +The runner must be in the `docker` group (so it can talk to the Docker socket without sudo) and reach all upstream services: + +```bash +sudo usermod -aG docker # then re-login the runner service +docker ps # must work without sudo for the runner user +``` + +Reachability checks the runner must pass: + +```bash +curl -fsS https://git.gnerim.ru/ # Gitea +curl -fsSI https://teamscore.gitlab.yandexcloud.net/ # GitLab +curl -fsSI http://jenkins.yc.devwebzavod.ru:8080/ # Jenkins (via static route) +curl -fsSI http://flights-ui.devwebzavod.ru/ # Customer URL (via static route) +``` + +### 4. GitLab Personal Access Token + +GitLab → User Settings → Access Tokens → create with scopes `api` and `write_repository`. Store as Gitea Actions secret `GITLAB_PAT`. + +### 5. Allow self-approve on GitLab project + +GitLab → flights-front project → Settings → Merge requests → Approval rules → uncheck **"Prevent approval by author"**. + +Verify by running (locally, after PAT is in place — script is created in Task 17 of the plan): + +```bash +GITLAB_PAT= ./scripts/ci/check-gitlab-project.sh +``` + +It prints the numeric project ID (store as `GITLAB_PROJECT_ID` secret) and confirms self-approve is allowed. + +### 6. Jenkins remote trigger token + +Jenkins → `Aeroflot2/Flights-Front-Dev` job → Configure → check **"Trigger builds remotely"** → set token (e.g. `flights-cd-trigger`). Store as `JENKINS_TRIGGER_TOKEN`. + +Also: Jenkins → User → Configure → API Token → Add new token. Store username as `JENKINS_USER`, token as `JENKINS_API_TOKEN`. + +### 7. Telegram bot + +Use existing bot or create via @BotFather. Get the chat_id by sending a message and querying `https://api.telegram.org/bot/getUpdates`. Store as `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`. + +### 8. Gitea Actions secrets summary + +Repo → Settings → Actions → Secrets — set all of: + +| Secret | Purpose | +|---|---| +| `BASIC_AUTH_USER`, `BASIC_AUTH_PASS` | nginx htpasswd | +| `MAP_TILE_URL` | Default `/map/api/tile/{z}/{x}/{y}.jpeg` | +| `API_BASE_URL` | Default `/api` | +| `GITLAB_PAT`, `GITLAB_PROJECT_ID` | GitLab MR API | +| `JENKINS_USER`, `JENKINS_API_TOKEN`, `JENKINS_TRIGGER_TOKEN` | Jenkins API | +| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | Notifications | +| `GITHUB_TOKEN` | Auto-provided by Gitea Actions — no manual setup required | + +## Verifying failure paths + +Run at least the rollback and "release blocked" rehearsals once before declaring the pipeline production-grade. + +### A: e2e fail → rollback + +Push a commit that adds `console.error('rehearsal')` somewhere that runs on every page (e.g. `src/routes/layout.tsx`). Workflow A runs, e2e fails on the console-gate, rollback to `:previous` triggers. Verify: + +- Telegram message: `❌ ci-deploy FAILED at step "Run Playwright e2e" — rolled back to ` +- `https://ui-dashboard.gnerim.ru/` still serves the previous version (check the page or `docker inspect flights-web`). + +Revert the rehearsal commit when done. + +### A: rollback itself fails + +```bash +ssh pve-201 'docker rmi flights-web:previous' +``` + +Then push a commit that fails e2e. Rollback step finds no `:previous` and bails. Verify: + +- Telegram message: `🔥 ci-deploy ROLLBACK FAILED — site is DOWN` +- `https://ui-dashboard.gnerim.ru/` returns 502. +- Manual recovery: `ssh pve-201 'docker stop flights-web 2>/dev/null; docker rm flights-web 2>/dev/null; docker run -d --name flights-web --restart unless-stopped -p 127.0.0.1:8081:8080 flights-web:'`. + +### B: blocked on A not green + +Trigger Workflow B (manual or tag) for a SHA that has no green Workflow A run. Verify: + +- Telegram message: `⚠️ release blocked — workflow ci-deploy is not green for ` +- B exits early; nothing changes in GitLab. + +### B: Jenkins poll timeout + +Temporarily edit `scripts/ci/jenkins-trigger-and-wait.sh` to change the default: +```bash +TIMEOUT="${JENKINS_TIMEOUT:-30}" # was 1800 +``` +Push to a throwaway branch, trigger Workflow B from that branch via the Gitea UI, and confirm: +- Telegram message: `❌ release FAILED at Jenkins build` (because polling gives up after 30s) +- The Jenkins job itself may continue running — that's fine, it's outside our control. + +**Restore the original 1800 default** and force-delete the throwaway branch when done. + +## Manual recovery scenarios + +### Workflow B failed at step 12-13 (Jenkins) — MR merged but customer site stale + +GitLab is already at the new commit; Jenkins didn't deploy. Recovery: + +1. Open Jenkins UI → click "Build Now" on the same job, or +2. Push a new commit to GitLab to re-trigger Jenkins polling (if it's set up that way), or +3. Re-run Workflow B from a green Workflow A — but only if you also pushed new code; otherwise B will sync a no-op and skip. + +### Container running but nginx returns 502 + +Check the bind: + +```bash +ssh pve-201 +docker ps --filter name=flights-web +curl -v http://127.0.0.1:8081/ # should return 200 (or whatever the SSR root returns) +sudo nginx -t && sudo systemctl reload nginx +``` + +If the container died, the Restart policy `unless-stopped` should bring it back. If not: + +```bash +docker logs flights-web --tail 200 +docker stop flights-web 2>/dev/null; docker rm flights-web 2>/dev/null +docker run -d --name flights-web --restart unless-stopped -p 127.0.0.1:8081:8080 flights-web:current +``` diff --git a/deployment/nginx/ui-dashboard.gnerim.ru.conf b/deployment/nginx/ui-dashboard.gnerim.ru.conf new file mode 100644 index 00000000..9963feed --- /dev/null +++ b/deployment/nginx/ui-dashboard.gnerim.ru.conf @@ -0,0 +1,52 @@ +# Production vhost for ui-dashboard.gnerim.ru. +# Symlink into /etc/nginx/sites-enabled/ and reload nginx. +# TLS certs assumed to exist via certbot (separate process). + +server { + listen 80; + server_name ui-dashboard.gnerim.ru; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name ui-dashboard.gnerim.ru; + + ssl_certificate /etc/letsencrypt/live/ui-dashboard.gnerim.ru/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ui-dashboard.gnerim.ru/privkey.pem; + + auth_basic "ui-dashboard"; + auth_basic_user_file /etc/nginx/htpasswd/ui-dashboard; + + # SSR app on loopback (container bound to 127.0.0.1:8081) + location / { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # Long-poll friendliness for any future SignalR / SSE + proxy_read_timeout 300s; + proxy_buffering off; + } + + # API proxy — bypass basic auth (gates HTML, not API). + # Static route on the host sends 172.18.0.0/16 via 192.168.88.58 (webzavod). + # /etc/hosts pins flights.test.aeroflot.ru → 172.18.0.121. + location /api/ { + auth_basic off; + proxy_pass https://flights.test.aeroflot.ru; + proxy_set_header Host flights.test.aeroflot.ru; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_ssl_server_name on; + } + + location /map/api/ { + auth_basic off; + proxy_pass https://flights.test.aeroflot.ru; + proxy_set_header Host flights.test.aeroflot.ru; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_ssl_server_name on; + } +} diff --git a/package.json b/package.json index 9a27ca4b..2e392459 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "check-coverage": "node scripts/ci/check-coverage-delta.mjs", "test:e2e": "playwright test", "test:e2e:angular": "playwright test --config=playwright-angular.config.ts tests/e2e-angular/cross-app/", + "test:ci": "bash -c 'shopt -s nullglob; for f in tests/ci/test-*.sh; do echo \"--- $f ---\"; bash \"$f\" || exit 1; done'", "proxy": "node scripts/api-proxy.mjs", "dev:full": "node scripts/dev-server.mjs", "compare:visual": "tsx tests/parity/visual/screenshot-diff-multi.ts && tsx tests/parity/visual/generate-report.ts", diff --git a/playwright.config.ts b/playwright.config.ts index d5a90e6a..c4148faa 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,16 +1,31 @@ import { defineConfig } from "@playwright/test"; +const baseURL = process.env.BASE_URL ?? "http://localhost:8080"; +const startLocalServer = !process.env.BASE_URL; + export default defineConfig({ testDir: "tests/e2e", timeout: 30000, use: { - baseURL: "http://localhost:8080", + baseURL, headless: true, + httpCredentials: + process.env.BASIC_AUTH_USER && process.env.BASIC_AUTH_PASS + ? { + username: process.env.BASIC_AUTH_USER, + password: process.env.BASIC_AUTH_PASS, + } + : undefined, }, - webServer: { - command: "pnpm dev", - url: "http://localhost:8080", - reuseExistingServer: true, - timeout: 30000, - }, + reporter: [["html", { open: "never" }], ["list"]], + ...(startLocalServer + ? { + webServer: { + command: "pnpm dev", + url: "http://localhost:8080", + reuseExistingServer: true, + timeout: 30000, + }, + } + : {}), }); diff --git a/scripts/ci/audit-console-allowlist.sh b/scripts/ci/audit-console-allowlist.sh new file mode 100755 index 00000000..ebbad7c1 --- /dev/null +++ b/scripts/ci/audit-console-allowlist.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# audit-console-allowlist.sh — find dead entries in tests/e2e/fixtures/console-allowlist.json. +# +# Usage: BASE_URL= ./scripts/ci/audit-console-allowlist.sh +# +# Strategy: stash the current allowlist, run e2e with it empty (so console-gate +# captures every message, attached to test artifacts), then diff captured +# messages against allowlist patterns. Patterns that didn't match anything +# are flagged as dead. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +ALLOWLIST="$REPO/tests/e2e/fixtures/console-allowlist.json" + +[ -f "$ALLOWLIST" ] || { echo "fatal: $ALLOWLIST not found" >&2; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "fatal: jq required" >&2; exit 2; } + +BACKUP=$(mktemp) +cp "$ALLOWLIST" "$BACKUP" +trap 'mv "$BACKUP" "$ALLOWLIST"' EXIT + +# Empty the allowlist +echo '{"patterns":[]}' > "$ALLOWLIST" + +# Run e2e, allow failures (we want the captured messages, not pass/fail) +echo "Running e2e with empty allowlist (captures all console messages)..." +( cd "$REPO" && pnpm test:e2e --reporter=line ) || true + +# Collect all console-violations.txt attachments +CAPTURED=$(mktemp) +find "$REPO/playwright-report" -name "console-violations.txt" -exec cat {} \; > "$CAPTURED" || true +find "$REPO/test-results" -name "console-violations.txt" -exec cat {} \; >> "$CAPTURED" 2>/dev/null || true + +# For each pattern in the original allowlist, check if any captured line matches +echo +echo "=== Allowlist audit ===" +PATTERNS=$(jq -r '.patterns[] | "\(.pattern)\t\(.reason)"' "$BACKUP") +DEAD=0 +LIVE=0 +while IFS=$'\t' read -r pat reason; do + [ -z "$pat" ] && continue + if grep -qE "$pat" "$CAPTURED" 2>/dev/null; then + echo "✅ live: $pat — $reason" + LIVE=$((LIVE + 1)) + else + echo "💀 dead: $pat — $reason" + DEAD=$((DEAD + 1)) + fi +done <<< "$PATTERNS" + +echo +echo "Summary: $LIVE live, $DEAD dead. Review dead entries — they may be safe to remove." + +rm -f "$CAPTURED" diff --git a/scripts/ci/check-gitlab-project.sh b/scripts/ci/check-gitlab-project.sh new file mode 100755 index 00000000..289a17a0 --- /dev/null +++ b/scripts/ci/check-gitlab-project.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# check-gitlab-project.sh — verify GitLab project setup for the release pipeline. +# +# Usage: GITLAB_PAT= ./scripts/ci/check-gitlab-project.sh +# +# Prints: +# - Numeric project ID (store as GITLAB_PROJECT_ID secret) +# - Whether "Prevent approval by author" is OFF (required for self-approve) +set -euo pipefail + +: "${GITLAB_PAT:?GITLAB_PAT required}" +GITLAB_HOST="${GITLAB_HOST:-https://teamscore.gitlab.yandexcloud.net}" +GITLAB_PROJECT_PATH="${GITLAB_PROJECT_PATH:-aeroflot2/flights-front}" + +command -v jq >/dev/null 2>&1 || { echo "fatal: jq required" >&2; exit 2; } + +ENCODED_PATH=$(printf '%s' "$GITLAB_PROJECT_PATH" | sed 's|/|%2F|g') +PROJECT_URL="${GITLAB_HOST}/api/v4/projects/${ENCODED_PATH}" + +echo "Querying $PROJECT_URL" +resp=$(curl -fsS -H "PRIVATE-TOKEN: ${GITLAB_PAT}" "$PROJECT_URL") || { + echo "fatal: project lookup failed (check PAT scopes: api + write_repository)" >&2 + exit 1 +} + +PROJECT_ID=$(printf '%s' "$resp" | jq -r '.id') +NAMESPACE=$(printf '%s' "$resp" | jq -r '.namespace.full_path') +DEFAULT_BRANCH=$(printf '%s' "$resp" | jq -r '.default_branch') + +echo +echo "✅ Project: ${NAMESPACE}/$(printf '%s' "$resp" | jq -r '.path')" +echo " ID: ${PROJECT_ID} ← store as Gitea secret GITLAB_PROJECT_ID" +echo " Default branch: ${DEFAULT_BRANCH}" + +# Check approval settings +APPROVALS_URL="${GITLAB_HOST}/api/v4/projects/${PROJECT_ID}/approvals" +appr=$(curl -fsS -H "PRIVATE-TOKEN: ${GITLAB_PAT}" "$APPROVALS_URL" 2>/dev/null) || appr='{}' +DISABLE_OVERRIDING=$(printf '%s' "$appr" | jq -r '.disable_overriding_approvers_per_merge_request // false') +PREVENT_AUTHOR=$(printf '%s' "$appr" | jq -r '.merge_requests_author_approval // null') + +echo +echo "Approval settings:" +echo " merge_requests_author_approval: ${PREVENT_AUTHOR}" +echo " disable_overriding_approvers: ${DISABLE_OVERRIDING}" + +# In GitLab API, merge_requests_author_approval=true means *allow* author approval. +case "$PREVENT_AUTHOR" in + true) echo " ✅ Self-approve allowed." ;; + false) echo " ❌ Self-approve BLOCKED. Uncheck 'Prevent approval by author' in project settings." ;; + *) echo " ⚠️ Could not read approval setting; verify in GitLab UI." ;; +esac + +# Check whether the runner can authenticate to push (try a HEAD on /info/refs). +echo +echo "Verifying push auth via HTTPS..." +PUSH_URL="${GITLAB_HOST}/${GITLAB_PROJECT_PATH}.git/info/refs?service=git-receive-pack" +http_code=$(curl -s -o /dev/null -w "%{http_code}" -u "oauth2:${GITLAB_PAT}" "$PUSH_URL" || echo "000") +case "$http_code" in + 200) echo " ✅ Push auth ok (HTTP 200)" ;; + *) echo " ⚠️ Push auth returned HTTP $http_code — verify PAT scope includes write_repository" ;; +esac diff --git a/scripts/ci/deploy-container.sh b/scripts/ci/deploy-container.sh new file mode 100755 index 00000000..d7494283 --- /dev/null +++ b/scripts/ci/deploy-container.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# deploy-container.sh — swap or rollback the flights-web container on the host. +# +# Usage: deploy-container.sh [--dry-run] +# +# `swap` — assumes the new image is tagged flights-web:${GITHUB_SHA}. +# Tags :current → :previous, :sha → :current, restarts container. +# `rollback` — runs flights-web:previous in place of :current, repoints :current. +# +# Env: +# GITHUB_SHA (required for swap) +# FLIGHTS_WEB_PORT (default 8081 — host port that nginx proxies to) +# IMAGE_NAME (default flights-web — set this to point at a registry later) +set -euo pipefail + +DRY_RUN=0 +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=1 + shift +fi + +CMD="${1:-}" +PORT="${FLIGHTS_WEB_PORT:-8081}" +IMAGE="${IMAGE_NAME:-flights-web}" + +run() { + if [ "$DRY_RUN" -eq 1 ]; then + printf 'docker %s\n' "$*" + else + docker "$@" + fi +} + +run_or_skip() { + # Same as run, but doesn't fail in real mode if the docker call fails. + if [ "$DRY_RUN" -eq 1 ]; then + printf 'docker %s\n' "$*" + else + docker "$@" || true + fi +} + +case "$CMD" in + swap) + : "${GITHUB_SHA:?GITHUB_SHA required for swap}" + SHORT_SHA="${GITHUB_SHA:0:7}" + # 1. Tag the currently-live image as :previous (skip if first deploy). + if [ "$DRY_RUN" -eq 1 ] || docker image inspect "${IMAGE}:current" >/dev/null 2>&1; then + run tag "${IMAGE}:current" "${IMAGE}:previous" + fi + # 2. Tag the new SHA as :current. + run tag "${IMAGE}:${SHORT_SHA}" "${IMAGE}:current" + # 3. Stop + remove existing container if present. + run_or_skip stop flights-web + run_or_skip rm flights-web + # 4. Run new container. + run run -d --name flights-web --restart unless-stopped \ + -p "127.0.0.1:${PORT}:8080" \ + "${IMAGE}:current" + ;; + rollback) + if [ "$DRY_RUN" -eq 0 ] && ! docker image inspect "${IMAGE}:previous" >/dev/null 2>&1; then + echo "fatal: ${IMAGE}:previous not found — cannot rollback" >&2 + exit 1 + fi + run_or_skip stop flights-web + run_or_skip rm flights-web + run run -d --name flights-web --restart unless-stopped \ + -p "127.0.0.1:${PORT}:8080" \ + "${IMAGE}:previous" + # Repoint :current to :previous so subsequent swaps have a sane baseline. + run tag "${IMAGE}:previous" "${IMAGE}:current" + ;; + *) + echo "usage: $0 [--dry-run] " >&2 + exit 2 + ;; +esac diff --git a/scripts/ci/install-htpasswd.sh b/scripts/ci/install-htpasswd.sh new file mode 100755 index 00000000..b89f8583 --- /dev/null +++ b/scripts/ci/install-htpasswd.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# install-htpasswd.sh — render /etc/nginx/htpasswd/ui-dashboard from env + reload nginx. +# +# Env (required): BASIC_AUTH_USER, BASIC_AUTH_PASS +# Env (optional): HTPASSWD_PATH (default /etc/nginx/htpasswd/ui-dashboard) +set -euo pipefail + +: "${BASIC_AUTH_USER:?BASIC_AUTH_USER required}" +: "${BASIC_AUTH_PASS:?BASIC_AUTH_PASS required}" +HTPASSWD_PATH="${HTPASSWD_PATH:-/etc/nginx/htpasswd/ui-dashboard}" + +# Use openssl APR1 hash (htpasswd from apache2-utils not always present). +# Format: : +HASH=$(openssl passwd -apr1 "$BASIC_AUTH_PASS") + +sudo mkdir -p "$(dirname "$HTPASSWD_PATH")" +echo "${BASIC_AUTH_USER}:${HASH}" | sudo tee "$HTPASSWD_PATH" >/dev/null +sudo chmod 644 "$HTPASSWD_PATH" + +sudo nginx -t +sudo nginx -s reload + +echo "ok: $HTPASSWD_PATH installed, nginx reloaded" diff --git a/scripts/ci/jenkins-trigger-and-wait.sh b/scripts/ci/jenkins-trigger-and-wait.sh new file mode 100755 index 00000000..2a094e92 --- /dev/null +++ b/scripts/ci/jenkins-trigger-and-wait.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# jenkins-trigger-and-wait.sh — fire a Jenkins job and wait for completion. +# +# Usage: +# jenkins-trigger-and-wait.sh # real mode (env-driven) +# jenkins-trigger-and-wait.sh --mock-mode # for tests +# +# Env (real mode): +# JENKINS_BASE_URL e.g. http://jenkins.yc.devwebzavod.ru:8080 +# JENKINS_JOB_PATH e.g. /job/Aeroflot2/job/Flights-Front-Dev +# JENKINS_USER, JENKINS_API_TOKEN +# JENKINS_TRIGGER_TOKEN +# JENKINS_TIMEOUT seconds (default 1800) +# JENKINS_POLL_INTERVAL seconds (default 10) +set -euo pipefail + +MODE=real +FIXTURE="" +if [ "${1:-}" = "--mock-mode" ]; then + MODE=mock + FIXTURE="${2:-}" + [ -n "$FIXTURE" ] || { echo "usage: $0 --mock-mode " >&2; exit 2; } + command -v jq >/dev/null 2>&1 || { echo "fatal: jq required for --mock-mode" >&2; exit 2; } +fi + +POLL_INTERVAL="${JENKINS_POLL_INTERVAL:-10}" +TIMEOUT="${JENKINS_TIMEOUT:-1800}" + +if [ "$MODE" = real ]; then + : "${JENKINS_BASE_URL:?required}" + : "${JENKINS_JOB_PATH:?required}" + : "${JENKINS_USER:?required}" + : "${JENKINS_API_TOKEN:?required}" + : "${JENKINS_TRIGGER_TOKEN:?required}" +fi + +# ── Mock mode: walk fixture deterministically ───────────────────────────────── +if [ "$MODE" = mock ]; then + QUEUE_URL=$(jq -r '.trigger_response.headers.Location' "$FIXTURE") + echo "triggered (mock): queue=$QUEUE_URL" + + # Walk queue polls until we get an executable. + count=$(jq '.queue_polls | length' "$FIXTURE") + BUILD_URL="" + for i in $(seq 0 $((count - 1))); do + body=$(jq -c ".queue_polls[$i].body" "$FIXTURE") + exe_url=$(printf '%s' "$body" | jq -r '.executable.url // empty') + if [ -n "$exe_url" ]; then + BUILD_URL="$exe_url" + break + fi + echo "queue poll $((i + 1)): not yet" + done + [ -n "${BUILD_URL:-}" ] || { echo "fatal: queue never produced executable" >&2; exit 1; } + echo "build url (mock): $BUILD_URL" + + # Walk build polls until result != null. + count=$(jq '.build_polls | length' "$FIXTURE") + for i in $(seq 0 $((count - 1))); do + body=$(jq -c ".build_polls[$i].body" "$FIXTURE") + result=$(printf '%s' "$body" | jq -r '.result // empty') + number=$(printf '%s' "$body" | jq -r '.number') + if [ -n "$result" ]; then + if [ "$result" = "SUCCESS" ]; then + echo "build #${number} SUCCESS" + exit 0 + else + echo "build #${number} ${result}" >&2 + exit 1 + fi + fi + echo "build poll $((i + 1)): building" + done + echo "fatal: build never completed within fixture" >&2 + exit 1 +fi + +# ── Real mode ───────────────────────────────────────────────────────────────── +TRIGGER_URL="${JENKINS_BASE_URL}${JENKINS_JOB_PATH}/build?token=${JENKINS_TRIGGER_TOKEN}" +echo "triggering: $TRIGGER_URL" + +# -D - dumps headers; -o /dev/null discards body. We need the Location header. +HEADERS=$(curl -fsS -X POST -u "${JENKINS_USER}:${JENKINS_API_TOKEN}" -D - -o /dev/null "$TRIGGER_URL") +QUEUE_URL=$(printf '%s' "$HEADERS" | grep -i '^Location:' | head -1 | sed 's/^[Ll]ocation:[[:space:]]*//' | tr -d '\r\n') +[ -n "$QUEUE_URL" ] || { echo "fatal: no Location header from Jenkins" >&2; exit 1; } +echo "queue: $QUEUE_URL" + +# Poll queue for executable.url. START covers both queue + build phases. +START=$(date +%s) +BUILD_URL="" +while [ -z "$BUILD_URL" ]; do + resp=$(curl -fsS -u "${JENKINS_USER}:${JENKINS_API_TOKEN}" "${QUEUE_URL}api/json") + BUILD_URL=$(printf '%s' "$resp" | jq -r '.executable.url // empty') + [ -n "$BUILD_URL" ] && break + now=$(date +%s) + if [ $((now - START)) -ge "$TIMEOUT" ]; then + echo "fatal: queue timeout after ${TIMEOUT}s" >&2 + exit 1 + fi + sleep "$POLL_INTERVAL" +done +echo "build: $BUILD_URL" + +# Poll build for result. Timeout window is shared with queue phase (START not reset). +while :; do + resp=$(curl -fsS -u "${JENKINS_USER}:${JENKINS_API_TOKEN}" "${BUILD_URL}api/json") + result=$(printf '%s' "$resp" | jq -r '.result // empty') + number=$(printf '%s' "$resp" | jq -r '.number') + if [ -n "$result" ]; then + if [ "$result" = "SUCCESS" ]; then + echo "build #${number} SUCCESS" + exit 0 + else + echo "build #${number} ${result} — see ${BUILD_URL}console" >&2 + exit 1 + fi + fi + now=$(date +%s) + if [ $((now - START)) -ge "$TIMEOUT" ]; then + echo "fatal: build timeout after ${TIMEOUT}s — see ${BUILD_URL}console" >&2 + exit 1 + fi + sleep "$POLL_INTERVAL" +done diff --git a/scripts/ci/notify-telegram.sh b/scripts/ci/notify-telegram.sh new file mode 100755 index 00000000..db070636 --- /dev/null +++ b/scripts/ci/notify-telegram.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# notify-telegram.sh — post a Telegram message for a CI stage. +# +# Usage: notify-telegram.sh [--dry-run] [] +# +# Env (required unless --dry-run): +# TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID +# Env (always read for context): +# GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SERVER_URL, GITHUB_SHA, GITHUB_WORKFLOW +set -euo pipefail + +DRY_RUN=0 +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=1 + shift +fi + +VERB="${1:-}" +STAGE="${2:-}" +EXTRA="${3:-}" +LOG_PATH="${4:-}" + +case "$VERB" in + start|ok|fail) ;; + *) echo "usage: $0 [--dry-run] []" >&2; exit 2 ;; +esac + +[ -n "$STAGE" ] || { echo "usage: $0 [--dry-run] []" >&2; exit 2; } + +if [ "$DRY_RUN" -eq 0 ]; then + : "${TELEGRAM_BOT_TOKEN:?TELEGRAM_BOT_TOKEN required}" + : "${TELEGRAM_CHAT_ID:?TELEGRAM_CHAT_ID required}" +fi + +REPO="${GITHUB_REPOSITORY:-unknown/repo}" +RUN_ID="${GITHUB_RUN_ID:-0}" +SERVER="${GITHUB_SERVER_URL:-https://git.gnerim.ru}" +SHA="${GITHUB_SHA:-unknown}" +SHORT_SHA="${SHA:0:7}" +RUN_URL="${SERVER}/${REPO}/actions/runs/${RUN_ID}" + +case "$VERB" in + start) ICON="🚀"; HEAD="${ICON} ${STAGE} started" ;; + ok) ICON="✅"; HEAD="${ICON} ${STAGE} passed" ;; + fail) ICON="❌"; HEAD="${ICON} ${STAGE} FAILED${EXTRA:+ at step \"${EXTRA}\"}" ;; +esac + +# Body is plain text (no HTML escaping needed for our content). +BODY="${HEAD} +commit: ${SHORT_SHA} +gitea run: ${RUN_URL}" + +if [ "$VERB" = "fail" ] && [ -n "$LOG_PATH" ] && [ -f "$LOG_PATH" ]; then + TAIL_LINES=$(tail -n 30 "$LOG_PATH") + TAIL_COUNT=$(printf '%s\n' "$TAIL_LINES" | wc -l | tr -d ' ') + BODY="${BODY} + +last ${TAIL_COUNT} lines: +${TAIL_LINES}" +fi + +if [ "$DRY_RUN" -eq 1 ]; then + printf '%s\n' "$BODY" + exit 0 +fi + +# Send via curl. Use --data-urlencode to avoid encoding pitfalls. +curl -fsS -X POST \ + "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${BODY}" \ + --data-urlencode "disable_web_page_preview=true" \ + >/dev/null diff --git a/scripts/ci/sync-to-gitlab.sh b/scripts/ci/sync-to-gitlab.sh new file mode 100755 index 00000000..913c5d9b --- /dev/null +++ b/scripts/ci/sync-to-gitlab.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# sync-to-gitlab.sh — copy source from this repo to a target deployment dir. +# +# Usage: sync-to-gitlab.sh +# +# Same logic as scripts/sync-to-flights-front.sh, but takes the target as a +# required argument and emits machine-friendly output (no "next steps" hints). +set -euo pipefail + +SRC_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +TARGET_DIR="${1:-}" + +if [ -z "$TARGET_DIR" ]; then + echo "usage: $0 " >&2 + exit 2 +fi +if [ ! -d "$TARGET_DIR" ]; then + echo "fatal: target directory does not exist: $TARGET_DIR" >&2 + exit 1 +fi + +DEPLOY_ROOT="$(cd "$TARGET_DIR/.." && pwd)" + +echo "syncing $SRC_DIR → $TARGET_DIR" + +# 1. Clean target (preserve node_modules, .git, .dockerignore) +find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \ + ! -name "node_modules" \ + ! -name ".dockerignore" \ + ! -name ".git" \ + -exec rm -rf {} + + +# 2. Copy source files +cp -r "$SRC_DIR/src" "$TARGET_DIR/src" +cp -r "$SRC_DIR/tests" "$TARGET_DIR/tests" +cp -r "$SRC_DIR/scripts" "$TARGET_DIR/scripts" +cp -r "$SRC_DIR/config" "$TARGET_DIR/config" +cp "$SRC_DIR/package.json" "$TARGET_DIR/package.json" +cp "$SRC_DIR/pnpm-lock.yaml" "$TARGET_DIR/pnpm-lock.yaml" +cp "$SRC_DIR/tsconfig.json" "$TARGET_DIR/tsconfig.json" +cp "$SRC_DIR/modern.config.ts" "$TARGET_DIR/modern.config.ts" +cp "$SRC_DIR/module-federation.config.ts" "$TARGET_DIR/module-federation.config.ts" +cp "$SRC_DIR/vitest.config.ts" "$TARGET_DIR/vitest.config.ts" +cp "$SRC_DIR/playwright.config.ts" "$TARGET_DIR/playwright.config.ts" +cp "$SRC_DIR/eslint.config.js" "$TARGET_DIR/eslint.config.js" +cp "$SRC_DIR/Makefile" "$TARGET_DIR/Makefile" +# CLAUDE.md and AGENTS.md are intentionally NOT copied — internal toolchain +# notes that don't belong in the customer's repo. + +# Customer-specific Dockerfile (different defaults than ours) +cp "$SRC_DIR/Dockerfile.react" "$TARGET_DIR/Dockerfile" + +# 3. Copy deployment configs alongside the app +if [ -d "$SRC_DIR/deployment" ]; then + mkdir -p "$DEPLOY_ROOT/deployment" + cp -R "$SRC_DIR/deployment/." "$DEPLOY_ROOT/deployment/" +fi + +# 4. Cleanup artifacts that shouldn't ship +find "$TARGET_DIR" -maxdepth 1 -name "*.png" -delete 2>/dev/null || true +rm -rf "$TARGET_DIR/src/server/middleware/"*.test.ts 2>/dev/null || true +rm -f "$TARGET_DIR/scripts/sync-to-flights-front.sh" 2>/dev/null || true +rm -rf "$TARGET_DIR/scripts/ci" 2>/dev/null || true # our pipeline scripts; not for customer +rm -rf "$TARGET_DIR/scripts/phase-0" 2>/dev/null || true +rm -f "$TARGET_DIR/scripts/screenshot-diff.ts" 2>/dev/null || true +rm -f "$TARGET_DIR/playwright-angular.config.ts" 2>/dev/null || true +rm -rf "$TARGET_DIR/tests/e2e-angular" 2>/dev/null || true +rm -rf "$TARGET_DIR/tests/parity" 2>/dev/null || true +rm -rf "$TARGET_DIR/tests/ci" 2>/dev/null || true # our bash unit tests; not for customer +rm -rf "$TARGET_DIR/.gitea" 2>/dev/null || true # our workflows; not for customer +rm -rf "$TARGET_DIR/docs/superpowers" 2>/dev/null || true + +# 5. Ensure .dockerignore exists at target +if [ ! -f "$TARGET_DIR/.dockerignore" ]; then + cat > "$TARGET_DIR/.dockerignore" <<'EOF' +node_modules +dist +coverage +test-results +playwright-report +.playwright-mcp +*.png +*.md +.git +.claude +.gstack +EOF +fi + +echo "ok: sync complete" diff --git a/scripts/ci/wait-for-url.sh b/scripts/ci/wait-for-url.sh new file mode 100755 index 00000000..48535157 --- /dev/null +++ b/scripts/ci/wait-for-url.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# wait-for-url.sh — curl with retry until success or attempts exhausted. +# +# Usage: wait-for-url.sh [] [] +# Env (optional): BASIC_AUTH_USER, BASIC_AUTH_PASS — if set, sent as basic auth. +set -euo pipefail + +URL="${1:-}" +MAX_ATTEMPTS="${2:-30}" +DELAY="${3:-2}" + +if [ -z "$URL" ]; then + echo "usage: $0 [] []" >&2 + exit 2 +fi + +# bash 3.2-safe: expand array only when non-empty. +AUTH_ARGS=() +if [ -n "${BASIC_AUTH_USER:-}" ] && [ -n "${BASIC_AUTH_PASS:-}" ]; then + AUTH_ARGS=(--user "${BASIC_AUTH_USER}:${BASIC_AUTH_PASS}") +fi + +attempt=1 +while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do + if curl -fsS ${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"} -o /dev/null "$URL"; then + echo "ok: $URL ($attempt attempt(s))" + exit 0 + fi + if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then + sleep "$DELAY" + fi + attempt=$((attempt + 1)) +done + +echo "fail: $URL did not return 2xx after $MAX_ATTEMPTS attempts" >&2 +exit 1 diff --git a/scripts/sync-to-flights-front.sh b/scripts/sync-to-flights-front.sh index dad4fb52..6772b087 100755 --- a/scripts/sync-to-flights-front.sh +++ b/scripts/sync-to-flights-front.sh @@ -1,142 +1,20 @@ #!/usr/bin/env bash +# sync-to-flights-front.sh — local dev convenience wrapper. +# +# Calls scripts/ci/sync-to-gitlab.sh with the local sibling-repo default. +# CI uses scripts/ci/sync-to-gitlab.sh directly with a fresh clone target. set -euo pipefail -# Sync the React app from this repo to the flights-front deployment repo. -# -# Usage: -# ./scripts/sync-to-flights-front.sh [target-dir] -# -# Default target: /Users/gnezim/_projects/tims/flights-front/Aeroflot.Flights.Front -# -# The deploy repo has the shape: -# flights-front/ -# Aeroflot.Flights.Front/ ← app source (target of this sync) -# deployment/ ← k8s manifests, ci scripts, etc. -# Anything under this repo's top-level deployment/ is copied to the -# sibling deployment/ in the deploy repo so cluster config lives with -# the source that consumes its env vars. - SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)" TARGET_DIR="${1:-/Users/gnezim/_projects/tims/flights-front/Aeroflot.Flights.Front}" + +"$SRC_DIR/scripts/ci/sync-to-gitlab.sh" "$TARGET_DIR" + DEPLOY_ROOT="$(cd "$TARGET_DIR/.." && pwd)" -if [ ! -d "$TARGET_DIR" ]; then - echo "Error: target directory does not exist: $TARGET_DIR" - exit 1 -fi - -echo "Syncing: $SRC_DIR → $TARGET_DIR" -echo " $SRC_DIR/deployment → $DEPLOY_ROOT/deployment" -echo "" - -# ── Step 1: Clean target (preserve node_modules, .git, .dockerignore) ──────── - -echo "1/6 Cleaning target directory..." -find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \ - ! -name "node_modules" \ - ! -name ".dockerignore" \ - -exec rm -rf {} + - -# ── Step 2: Copy source files ──────────────────────────────────────────────── - -echo "2/6 Copying source files..." - -# App source & config -cp -r "$SRC_DIR/src" "$TARGET_DIR/src" -cp -r "$SRC_DIR/tests" "$TARGET_DIR/tests" -cp -r "$SRC_DIR/scripts" "$TARGET_DIR/scripts" -# Modern.js publicDir — fonts, images, leaflet icons, favicons. Copied to -# dist/standalone/public/ at build time. Without these the app serves the -# SPA index for /assets/** and breaks @font-face, img references, tile icons. -cp -r "$SRC_DIR/config" "$TARGET_DIR/config" -cp "$SRC_DIR/package.json" "$TARGET_DIR/package.json" -cp "$SRC_DIR/pnpm-lock.yaml" "$TARGET_DIR/pnpm-lock.yaml" -cp "$SRC_DIR/tsconfig.json" "$TARGET_DIR/tsconfig.json" -cp "$SRC_DIR/modern.config.ts" "$TARGET_DIR/modern.config.ts" -cp "$SRC_DIR/module-federation.config.ts" "$TARGET_DIR/module-federation.config.ts" -cp "$SRC_DIR/vitest.config.ts" "$TARGET_DIR/vitest.config.ts" -cp "$SRC_DIR/playwright.config.ts" "$TARGET_DIR/playwright.config.ts" -cp "$SRC_DIR/eslint.config.js" "$TARGET_DIR/eslint.config.js" -cp "$SRC_DIR/Makefile" "$TARGET_DIR/Makefile" -cp "$SRC_DIR/CLAUDE.md" "$TARGET_DIR/CLAUDE.md" -cp "$SRC_DIR/AGENTS.md" "$TARGET_DIR/AGENTS.md" - -# Dockerfile — use the SSR-specific one -cp "$SRC_DIR/Dockerfile.react" "$TARGET_DIR/Dockerfile" - -# ── Step 3: Copy deployment configs to the sibling deployment/ dir ────────── - -echo "3/6 Copying deployment configs..." - -if [ -d "$SRC_DIR/deployment" ]; then - mkdir -p "$DEPLOY_ROOT/deployment" - # Mirror deployment/ without nuking unrelated files under the deploy - # repo's deployment dir — copy contents, don't wipe the target. - cp -R "$SRC_DIR/deployment/." "$DEPLOY_ROOT/deployment/" - echo " copied $(find "$SRC_DIR/deployment" -type f | wc -l | tr -d ' ') file(s) into $DEPLOY_ROOT/deployment" -else - echo " (no deployment/ dir in source repo — skipping)" -fi - -# ── Step 4: Clean up artifacts that shouldn't be in the target ──────────────── - -echo "4/6 Cleaning up artifacts..." - -# Remove debug screenshots -find "$TARGET_DIR" -maxdepth 1 -name "*.png" -delete 2>/dev/null || true - -# Remove Angular-specific files that may have leaked -rm -rf "$TARGET_DIR/src/server/middleware/"*.test.ts 2>/dev/null || true - -# Remove this sync script from the target (it's only needed in the source repo) -rm -f "$TARGET_DIR/scripts/sync-to-flights-front.sh" 2>/dev/null || true - -# Remove dev-server proxy script (flights-front has its own deployment) -# Keep it — it's useful for local dev - -# Remove Angular comparison scripts -rm -rf "$TARGET_DIR/scripts/phase-0" 2>/dev/null || true -rm -f "$TARGET_DIR/scripts/screenshot-diff.ts" 2>/dev/null || true - -# Remove playwright-angular config if copied -rm -f "$TARGET_DIR/playwright-angular.config.ts" 2>/dev/null || true - -# Remove test files that reference Angular -rm -rf "$TARGET_DIR/tests/e2e-angular" 2>/dev/null || true -rm -rf "$TARGET_DIR/tests/parity" 2>/dev/null || true - -# ── Step 5: Ensure .dockerignore exists ────────────────────────────────────── - -echo "5/6 Ensuring .dockerignore..." - -if [ ! -f "$TARGET_DIR/.dockerignore" ]; then - cat > "$TARGET_DIR/.dockerignore" << 'DOCKERIGNORE' -node_modules -dist -coverage -test-results -playwright-report -.playwright-mcp -*.png -*.md -.git -.claude -.gstack -DOCKERIGNORE -fi - -# ── Step 6: Summary ───────────────────────────────────────────────────────── - -echo "6/6 Done!" -echo "" -echo "Synced app files:" -ls -1 "$TARGET_DIR" | grep -v node_modules -if [ -d "$DEPLOY_ROOT/deployment" ]; then - echo "" - echo "Synced deployment files:" - (cd "$DEPLOY_ROOT" && find deployment -type f | sort) -fi -echo "" +echo +echo "Synced app files at $TARGET_DIR" +echo echo "Next steps:" echo " cd $TARGET_DIR" echo " pnpm install # if lock file changed" diff --git a/src/features/online-board/components/OnlineBoardFilter.scss b/src/features/online-board/components/OnlineBoardFilter.scss index 6f15de5f..8e92065c 100644 --- a/src/features/online-board/components/OnlineBoardFilter.scss +++ b/src/features/online-board/components/OnlineBoardFilter.scss @@ -3,11 +3,22 @@ @use "../../../styles/colors" as colors; @use "../../../styles/shadows" as shadows; +// Schedule-parity sidebar: the OnlineBoard filter's city/airport selector +// now uses the same visual language as `ScheduleFilter`: +// - plain white `section.frame` (no $blue-extra-light tint), +// - flat accordion headers without PrimeNG chrome (no shadows / borders / +// pill radii), slightly muted label color, chevron on the right, +// - shared `.filter-content`, `.label--filter`, `.input--filter`, +// `.calendar-input-wrapper`, `.search-button` rules matching Schedule's. + .online-board-filter { section.frame { - background-color: colors.$blue-extra-light; + background-color: colors.$white; } + // Accordion tab list — kept so the user can toggle between the + // "Flight number" and "Route" search modes. Visually it's now just + // a clickable row, not a pill, so it reads like a subtle divider. .p-accordion { .p-accordion-tab { .p-accordion-header { @@ -15,15 +26,17 @@ margin: 0; a { - background-color: transparent; + background: transparent; border: none; color: colors.$blue; border-radius: 0; - padding: 0 vars.$space-l 0 vars.$space-xl; - height: vars.$button-height; + padding: vars.$space-m vars.$space-xl; + height: auto; + min-height: 0; display: flex; align-items: center; font-weight: fonts.$font-bold; + font-size: fonts.$font-size-m; cursor: pointer; text-decoration: none; @@ -35,46 +48,46 @@ } } + // The currently-active tab header reads slightly muted (matches + // Schedule's plain form label) and drops any pill/shadow chrome. &.p-highlight { a { - background-color: colors.$white; + background: transparent; border: none; - color: colors.$text-color; + color: colors.$gray; + font-weight: fonts.$font-medium; } - border-bottom: none; } } .p-accordion-content { + // Schedule uses a flat white panel — no shadow / bottom border. + box-shadow: none; + border-bottom: none; + padding: 0 vars.$space-xl vars.$space-xl; + background: colors.$white; + } + + &:first-child .p-accordion-header a { + border-radius: vars.$border-radius vars.$border-radius 0 0; + } + + // Thin hairline between tabs, matching Schedule's subtle section + // divider above `Популярные разделы`. + &:not(:last-child) .p-accordion-header { border-bottom: 1px solid colors.$border; - @include shadows.box-shadow-small; - padding: vars.$space-s vars.$space-xl vars.$space-xl vars.$space-xl; } - &:first-child { - .p-accordion-header { - a { - border-radius: vars.$border-radius vars.$border-radius 0 0; - } - } - } - - &:not(:last-child) { - .p-accordion-header { - border-bottom: 1px solid colors.$border; - } - } - - &:last-child { - .p-accordion-content { - border-radius: 0 0 vars.$border-radius vars.$border-radius; - border: none; - } + &:last-child .p-accordion-content { + border-radius: 0 0 vars.$border-radius vars.$border-radius; + border: none; } } } + // Mirrors Angular `.label--filter` — 12px regular $gray with + // $label-margin-bottom under the label. Matches ScheduleFilter. .label--filter { display: block; margin-right: vars.$space-xl; @@ -155,49 +168,29 @@ // `styles/_icons.scss`. .wrapper--time-selector { - margin-top: vars.$space-xl; + // Schedule uses the compact (inline label + value) layout everywhere, + // so drop the legacy top margin that OnlineBoard inherited. + display: flex; + flex-direction: column; + gap: vars.$space-s2; + + .time-selector__label-value { + display: flex; + justify-content: space-between; + align-items: baseline; + } .time-selector__label { font-size: fonts.$font-size-s; - color: colors.$gray; - margin-bottom: vars.$space-s; - } - - .time-selector { - padding: 0 vars.$space-s; + color: colors.$light-gray; + margin: 0; } .time-selector__value { font-size: fonts.$font-size-s; - color: colors.$gray; - margin-top: vars.$space-s; - text-align: right; - } - - &.compact-view { - display: flex; - flex-direction: column; - gap: 6px; - - .time-selector__label-value { - display: flex; - justify-content: space-between; - align-items: baseline; - } - - .time-selector__label { - color: colors.$text-color; - font-size: fonts.$font-size-s; - font-weight: fonts.$font-bold; - margin-bottom: 0; - } - - .time-selector__value { - color: colors.$light-gray; - font-size: fonts.$font-size-s; - margin-top: 0; - text-align: left; - } + color: colors.$text-color; + font-weight: fonts.$font-medium; + margin: 0; } } @@ -245,10 +238,6 @@ } } - .calendar { - // margin-top removed: vertical rhythm now driven by .filter-content gap. - } - .calendar-input-wrapper { position: relative; display: flex; @@ -284,27 +273,29 @@ } .filter-content { - // Vertical rhythm between filter rows. Angular's accordion content - // separates fields by ~$space-l (15px); the previous default - // packed inputs about ~6 px tighter and surfaced as a measurable - // pixel-diff against Angular on the start page. + // Vertical rhythm between filter rows — same as Schedule + // ($space-l / 15px between fields). display: flex; flex-direction: column; gap: vars.$space-l; } .filter-button { - margin-top: 0; + margin-top: vars.$space-l; } + // Mirrors Angular `.search-button.color.blue-light` and Schedule's + // submit button: 48px tall pill with $blue-light background. .search-button { - margin-top: vars.$space-xl; width: 100%; height: vars.$standard-button-height; background-color: colors.$blue-light; color: colors.$white; border: none; border-radius: vars.$border-radius; + padding: 0 vars.$space-l; + font-size: fonts.$font-size-m; + font-weight: fonts.$font-bold; cursor: pointer; transition-duration: 0.2s; @@ -337,7 +328,8 @@ } } - // PrimeReact AutoComplete dropdown button — match Angular's subtle chevron + // PrimeReact AutoComplete dropdown button — subtle chevron, matches + // Schedule. .p-autocomplete-dropdown { background: transparent !important; border: none !important; diff --git a/src/features/schedule/components/DayGroupedFlightList.scss b/src/features/schedule/components/DayGroupedFlightList.scss index d4a205cf..9cafda8d 100644 --- a/src/features/schedule/components/DayGroupedFlightList.scss +++ b/src/features/schedule/components/DayGroupedFlightList.scss @@ -5,7 +5,22 @@ .day-grouped-flight-list { display: flex; flex-direction: column; - gap: 18px; + // Angular's `schedule-days .frame` lays day blocks flush — no gap; a + // single 1.3px hairline divider between siblings is drawn from the + // group's `::before` (see &__group + &__group below). + gap: 0; + + // When the column-headers row immediately follows the week-tabs inside + // the sticky card (the Angular-parity layout), cancel the WeekTabs + // bottom margin so the two sit flush together. + .week-tabs + &__column-headers, + .week-tabs + * + &__column-headers { + margin-top: 0; + } + .week-tabs:has(+ &__column-headers), + .week-tabs:has(+ * + &__column-headers) { + margin-bottom: 0; + } &__column-headers { display: grid; @@ -14,13 +29,19 @@ grid-template-columns: 80px 120px 100px minmax(45px, 240px) 100px 100px minmax(45px, 240px) 10px; gap: 16px; - padding: 14px 24px; + padding: 10px 24px; color: colors.$light-gray; font-size: 11px; font-weight: fonts.$font-medium; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid colors.$border; + // Put every column label on the same top baseline so "ВЫЛЕТ *" / + // "ПРИЛЕТ *" with their sort arrows don't push the row taller + // than "РЕЙС" / "ВРЕМЯ В ПУТИ". Each cell is top-aligned; the sort + // stack is absolute-positioned relative to the cell so it doesn't + // expand the row. + align-items: start; // The first two header labels span the first two grid columns. > span:nth-child(1) { grid-column: 1; } @@ -37,17 +58,30 @@ white-space: nowrap; } + &__col-asterisk { + margin-left: 2px; + font-size: 0.85em; + } + &__sort-group { display: inline-flex; flex-direction: column; gap: 0; line-height: 0; + // Shrink the two 6px triangles so they fit within one text line + // height without inflating the header row. + font-size: 0; } &__sort { background: transparent; border: 0; padding: 0; + // Global `button { min-height: 35px }` in styles/_buttons.scss would + // otherwise inflate each 6px triangle to 35px and double the column + // header row height. + min-height: 0; + height: 6px; cursor: pointer; color: colors.$border-blue; line-height: 0; @@ -60,23 +94,36 @@ &--active { color: colors.$blue; } } + // Angular's `schedule-days .frame` renders each day flat — no per-group + // border or rounded corners. A 1.3px top hairline divides siblings, + // inset 20px on both sides (see `flight-border-top` mixin in + // schedule-search-result.scss). The divider is drawn via a `::before` + // on every group except the first. &__group { - border: 1px solid colors.$border; - border-radius: vars.$border-radius; - overflow: hidden; background: colors.$white; + position: relative; } - // Angular's `schedule-search-result-day` wraps the whole row in - // `padding: $space-xl` (20px). Match it so the day group header has - // the same visual weight as the Angular reference. + &__group + &__group::before { + content: ""; + position: absolute; + top: 0; + left: vars.$space-xl; + right: vars.$space-xl; + height: 1.3px; + background: colors.$border; + z-index: 1; + } + + // Angular's `schedule-search-result-day` stacks the weekday above the + // date (small-gray "Вторник" on top, bold "21 Апреля" below). The + // chevron stays vertically centered against the stacked title. &__header { display: flex; align-items: center; gap: vars.$space-m2; padding: vars.$space-xl; - background: colors.$blue-extra-light; - border-bottom: 1px solid colors.$border; + background: colors.$white; cursor: pointer; user-select: none; @@ -90,6 +137,20 @@ } } + // Empty-day header should not change background on hover (mirrors + // Angular's disabled-looking row with cursor:default + opacity 0.5). + &__group--empty &__header:hover { + background: colors.$white; + } + + &__header-title { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + line-height: 1.15; + } + &__weekday { color: colors.$light-gray; font-size: 13px; @@ -101,19 +162,29 @@ color: colors.$blue-dark; font-size: fonts.$font-size-xl; font-weight: fonts.$font-medium; + line-height: 1.1; } + // The SVG path is an UP-pointing chevron (apex at top). Angular's + // `arrow-down-icon` uses the same path and applies `rotate(180deg)` + // by default (down, "click to expand") and `rotate(0deg)` when + // `[rotated]=true` i.e. expanded (up, "click to collapse"). &__chevron { margin-left: auto; color: colors.$blue; transition: transform 0.2s ease; + transform: rotate(0deg); &--collapsed { - transform: rotate(-90deg); + transform: rotate(180deg); } } - &__group--collapsed &__header { - border-bottom: none; + // Empty days (no flights for that date) render faded + no chevron, + // mirroring Angular's `[style.opacity]="scheduleItem.flights.length ? '1' : '0.5'"`. + &__group--empty &__header { + cursor: default; + opacity: 0.5; } + } diff --git a/src/features/schedule/components/ScheduleColumnHeaders.scss b/src/features/schedule/components/ScheduleColumnHeaders.scss new file mode 100644 index 00000000..5b5de2e9 --- /dev/null +++ b/src/features/schedule/components/ScheduleColumnHeaders.scss @@ -0,0 +1,118 @@ +/** + * Styles mirror Angular's `schedule-search-result-header.scss`: + * - Flex row, padding 0 20px, h-spacing 10px between cells. + * - Each cell is 56px tall (big-button-height), 12px uppercase gray text. + * - Sort buttons 12×12 px, 30% opacity faded, border on active. + * - Asterisk note (`*`) absolutely positioned top-right of the label. + */ + +@use "../../../styles/colors" as colors; +@use "../../../styles/variables" as vars; +@use "../../../styles/fonts" as fonts; + +.schedule-col-header { + display: flex; + align-items: center; + padding: 0 vars.$space-xl; + background: colors.$white; + + // 10px horizontal gap between cells (h-spacing $space-m in Angular). + > div + div { + margin-left: 10px; + } + + // When placed inside the sticky card directly after the week-tabs, + // the week-tabs bottom margin / card padding shouldn't pad the cell. + // Kill any margin-top so it sits flush. + margin-top: 0; + + &__flight { + width: 80px; + } + + &__company { + width: 120px; + } + + &__departure, + &__arrival { + flex: 1; + display: flex; + align-items: center; + } + + &__time { + width: 80px; + display: flex; + align-items: center; + justify-content: center; + } + + &__label { + font-size: 12px; + font-family: fonts.$font-family; + font-weight: fonts.$font-regular; + color: colors.$gray; + text-transform: uppercase; + line-height: normal; + display: flex; + align-items: center; + height: 56px; + + &--note { + position: relative; + padding-right: 10px; + } + } + + &__note { + position: absolute; + top: 8px; + right: 0; + font-size: 10px; + color: colors.$gray; + } + + &__sort-container { + margin-left: 5px; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + &__sort { + width: 12px; + height: 12px; + min-height: 0; // override global `button { min-height: 35px }` + min-width: 0; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: colors.$white; + border: 1px solid transparent; + border-radius: 0; + cursor: pointer; + color: colors.$gray; + opacity: 0.3; + line-height: 0; + transition: opacity 0.15s, border-color 0.15s; + + svg { + display: block; + width: 10px; + height: 10px; + } + + &:hover { + opacity: 0.8; + border-color: colors.$border; + } + + &--active { + opacity: 0.7; + border-color: #002776; + } + } +} diff --git a/src/features/schedule/components/ScheduleColumnHeaders.tsx b/src/features/schedule/components/ScheduleColumnHeaders.tsx new file mode 100644 index 00000000..dd6e8b4a --- /dev/null +++ b/src/features/schedule/components/ScheduleColumnHeaders.tsx @@ -0,0 +1,110 @@ +/** + * Sortable column header row for the schedule route-results page. + * + * Structure mirrors Angular's `schedule-search-result-header` — a flex + * row where each column cell contains a `.sort-label` (optionally with + * an absolutely-positioned asterisk note) and a `.sort-container` with + * stacked up/down sort buttons. Widths: РЕЙС 80px, АВИАКОМПАНИЯ 120px, + * ВЫЛЕТ flex:1, ВРЕМЯ 80px, ПРИЛЕТ flex:1. + * + * Sort state is owned by the parent page (ScheduleSearchPage), which + * also passes it to `DayGroupedFlightList` so the two stay in sync. + * + * @module + */ + +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import "./ScheduleColumnHeaders.scss"; + +export type ScheduleSortMode = + | "none" + | "departureUp" + | "departureDown" + | "timeUp" + | "timeDown" + | "arrivalUp" + | "arrivalDown"; + +export interface ScheduleColumnHeadersProps { + sortMode: ScheduleSortMode; + onSortChange: (mode: ScheduleSortMode) => void; +} + +export const ScheduleColumnHeaders: FC = ({ + sortMode, + onSortChange, +}) => { + const { t } = useTranslation(); + + const toggle = (mode: ScheduleSortMode): void => { + onSortChange(sortMode === mode ? "none" : mode); + }; + + const sortBtn = (mode: ScheduleSortMode, dir: "up" | "down") => ( + + ); + + return ( +
+
+
+ {t("SCHEDULE.COL-FLIGHT") || "РЕЙС"} +
+
+
+
+ {t("SCHEDULE.COL-AIRLINE") || "АВИАКОМПАНИЯ, БОРТ"} +
+
+
+
+ {t("SCHEDULE.COL-DEPARTURE") || "ВЫЛЕТ"} + +
+
+ {sortBtn("departureUp", "up")} + {sortBtn("departureDown", "down")} +
+
+
+
+ {t("SCHEDULE.COL-DURATION") || "ВРЕМЯ В ПУТИ"} +
+
+ {sortBtn("timeUp", "up")} + {sortBtn("timeDown", "down")} +
+
+
+
+ {t("SCHEDULE.COL-ARRIVAL") || "ПРИЛЕТ"} + +
+
+ {sortBtn("arrivalUp", "up")} + {sortBtn("arrivalDown", "down")} +
+
+
+ ); +}; diff --git a/src/features/schedule/components/ScheduleFlightBody.scss b/src/features/schedule/components/ScheduleFlightBody.scss index db8f8911..e6536984 100644 --- a/src/features/schedule/components/ScheduleFlightBody.scss +++ b/src/features/schedule/components/ScheduleFlightBody.scss @@ -269,8 +269,35 @@ &__timeline-time { flex-shrink: 0; + + // Shrink the TimeGroup time labels inside the route timeline and + // each leg row. The default (30px / light) is reserved for the + // collapsed summary row; inside the expanded body times read about + // half that — roughly matching Angular's `time-group size="small"` + // (16px). Apply to the sub-leg time columns as well. + .time-group__scheduled, + .time-group__actual { + font-size: 15px; + font-weight: fonts.$font-medium; + line-height: 1.2; + } } + &__leg-time { + .time-group__scheduled, + .time-group__actual { + font-size: 15px; + font-weight: fonts.$font-medium; + line-height: 1.2; + } + } + + // The `section` is the space between two timestamps in the route + // timeline. A single continuous 1px line runs horizontally across its + // center (via `::before`). The segment label ("1ч. 25мин.") sits + // ABOVE the line, and the section-number badge ("[1]") sits ON the + // line — its white background covers the line so the connector looks + // continuous (matching Angular's `connecting-flight-body` route bar). &__timeline-section { display: flex; flex-direction: column; @@ -280,16 +307,34 @@ color: colors.$light-gray; font-size: 13px; position: relative; + padding: 0 vars.$space-s; + + &::before { + content: ""; + position: absolute; + top: 50%; + left: 0; + right: 0; + border-top: 1px solid colors.$border; + z-index: 0; + } } + // The old structural bars are replaced by the section `::before`; keep + // the elements in the DOM (so TSX doesn't need to change) but hide them. &__timeline-bar { - flex: 1; - height: 1px; - width: 100%; - border-top: 1px solid colors.$border; + display: none; } + // [1]/[2] sits centered on the line. Absolute-position it at 50% so + // the number box vertically aligns with the connector, with its white + // background hiding the line behind the box. &__timeline-section-num { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1; display: inline-flex; align-items: center; justify-content: center; @@ -301,11 +346,16 @@ color: colors.$text-color; font-size: fonts.$font-size-s; font-weight: fonts.$font-medium; - margin-bottom: 2px; } + // "1ч. 25мин." label sits BELOW the line — reserve top space equal to + // the number-badge height (~22px) + a gap so the label clears the line. &__timeline-section-dur { - margin-top: 2px; + position: relative; + z-index: 1; + margin-top: 22px; + padding: 0 4px; + background: colors.$white; color: colors.$light-gray; font-size: fonts.$font-size-s; white-space: nowrap; @@ -338,9 +388,15 @@ &:not(:first-child):not(:last-child) { text-align: center; } } + // Angular renders the route-timeline city names at 22px / 300 (light), + // measured on the live `connecting-flight-body`. Bumps them well above + // the surrounding 14px body copy so the three-stop diagram reads like + // a headline. &__timeline-station-city { color: colors.$text-color; - font-weight: fonts.$font-medium; + font-size: fonts.$font-size-xl2; + font-weight: fonts.$font-light; + line-height: 1.2; } &__timeline-station-terminal { diff --git a/src/features/schedule/components/ScheduleStartPage.test.tsx b/src/features/schedule/components/ScheduleStartPage.test.tsx index 753c0301..9a02e6cb 100644 --- a/src/features/schedule/components/ScheduleStartPage.test.tsx +++ b/src/features/schedule/components/ScheduleStartPage.test.tsx @@ -76,10 +76,13 @@ vi.mock("@/shared/hooks/useCitySearch.js", () => ({ })); vi.mock("@/ui/city-autocomplete/index.js", () => ({ + // Controlled mock — reflects `value` prop changes so tests can assert + // post-update form state, not just mount-time prefill. CityAutocomplete: (props: Record) => ( ), SwapCityButton: (props: { onClick: () => void; testId?: string }) => ( @@ -188,35 +191,38 @@ describe("ScheduleStartPage", () => { }); }); - it("4.1.5-S1: one-way Route click prefills current ISO week dates (from clamped to today-1) + no return", () => { + it("4.1.5-S1: one-way Route click populates form with current ISO week dates (from clamped to today-1) + no return", () => { // 2026-05-15 (Fri) → raw Mon 2026-05-11, raw Sun 2026-05-17 // `from` is clamped to today−1 = 2026-05-14 so the route guard does // not redirect the search back to the start page. + // Same-page Schedule click updates form state directly (navigate to + // the same route would no-op), so we assert visible form state and + // submit the form to verify the dates landed in component state. render(); fireEvent.click(screen.getByTestId("popular-click-route")); - const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!); - expect(stored.departure).toBe("SVO"); - expect(stored.arrival).toBe("LED"); - expect(stored.withReturn).toBe(false); - expect(stored.dateFrom).toBe("20260514"); // clamped to today−1 (raw Mon was 2026-05-11) - expect(stored.dateTo).toBe("20260517"); // Sun - expect(stored.returnDateFrom).toBeUndefined(); - expect(stored.returnDateTo).toBeUndefined(); - expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule"); + expect(mockNavigate).not.toHaveBeenCalled(); + expect((screen.getByTestId("schedule-departure-input") as HTMLInputElement).value).toBe("SVO"); + expect((screen.getByTestId("schedule-arrival-input") as HTMLInputElement).value).toBe("LED"); + expect((screen.getByTestId("round-trip-toggle") as HTMLInputElement).checked).toBe(false); + expect(screen.queryByTestId("return-date-range-input")).toBeNull(); + + // Submit drives the dates from state into the URL — proves they were set. + fireEvent.submit(screen.getByTestId("schedule-search-form")); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517"); }); - it("4.1.5-S2: round-trip RouteWithBack click prefills current + next week dates (outbound from clamped)", () => { + it("4.1.5-S2: round-trip RouteWithBack click populates form with current + next week dates (outbound from clamped)", () => { // current week raw: 20260511-20260517 (clamped from: 20260514-20260517) // next week: 20260518-20260524 (unclamped — future) render(); fireEvent.click(screen.getByTestId("popular-click-roundtrip")); - const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!); - expect(stored.withReturn).toBe(true); - expect(stored.dateFrom).toBe("20260514"); // clamped - expect(stored.dateTo).toBe("20260517"); - expect(stored.returnDateFrom).toBe("20260518"); // next Mon - expect(stored.returnDateTo).toBe("20260524"); // next Sun - expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule"); + expect(mockNavigate).not.toHaveBeenCalled(); + expect((screen.getByTestId("round-trip-toggle") as HTMLInputElement).checked).toBe(true); + + fireEvent.submit(screen.getByTestId("schedule-search-form")); + expect(mockNavigate).toHaveBeenCalledWith( + "/ru-ru/schedule/route/SVO-LED-20260514-20260517/LED-SVO-20260518-20260524", + ); }); it("4.1.5-S3: prefill dates hydrate into form calendar state (no search on mount)", () => { @@ -250,13 +256,17 @@ describe("ScheduleStartPage", () => { expect((roundTripCheckbox as HTMLInputElement).checked).toBe(true); }); - it("writes prefill + navigates to onlineboard on Onlineboard-type popular click", () => { + it("Onlineboard-type Departure popular click stays on Schedule and sets departure only", () => { + // Deviation from Angular: Angular always navigates Arrival/Departure + // popular clicks to /onlineboard. We instead populate the relevant + // Schedule field in-place so users planning a route don't lose context. render(); fireEvent.click(screen.getByTestId("popular-click-onlineboard")); - expect(sessionStore.getRaw("afl-prefill:online-board")).toBe( - JSON.stringify({ tab: "route", departure: "LED" }), - ); - expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/onlineboard"); + expect(mockNavigate).not.toHaveBeenCalled(); + expect((screen.getByTestId("schedule-departure-input") as HTMLInputElement).value).toBe("LED"); + expect((screen.getByTestId("schedule-arrival-input") as HTMLInputElement).value).toBe(""); + // Onlineboard-type clicks must not write to either prefill slot. + expect(sessionStore.getRaw("afl-prefill:online-board")).toBeNull(); }); it("initializes form from sessionStorage prefill (legacy shape — withReturn only)", () => { @@ -288,14 +298,13 @@ describe("4.1.9-R: Current-Week label substitution", () => { vi.useRealTimers(); }); - it("4.1.9-R: start page renders with current-week dates pre-populated in session store on Route click", () => { + it("4.1.9-R: start page populates date range with current week on Route click", () => { render(); fireEvent.click(screen.getByTestId("popular-click-route")); - const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!); // Current week Sun for 2026-05-15 is 2026-05-17; `from` is clamped to // today−1 = 2026-05-14 so the range is inside Schedule's −1/+330 window. - expect(stored.dateFrom).toBe("20260514"); - expect(stored.dateTo).toBe("20260517"); + fireEvent.submit(screen.getByTestId("schedule-search-form")); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517"); }); }); diff --git a/src/features/schedule/components/WeekTabs.scss b/src/features/schedule/components/WeekTabs.scss index a69e4c68..e3f23dd0 100644 --- a/src/features/schedule/components/WeekTabs.scss +++ b/src/features/schedule/components/WeekTabs.scss @@ -2,61 +2,104 @@ @use "../../../styles/variables" as vars; @use "../../../styles/fonts" as fonts; +// Mirrors Angular's `date-tabs` + `tab-button` (see ClientApp/src/app/ +// toolkit/date-tabs/*). Each tab is a flat rectangle on $blue-extra-light +// with a 1px border-right between siblings and a 1px border-bottom along +// the row; the active tab is white with no bottom border so it visually +// "merges" into the content below. Carousel-style chevron arrows sit at +// each end (top-rounded outer corner, $blue-extra-light fill). .week-tabs { display: flex; align-items: stretch; - gap: 4px; - background: rgba(255, 255, 255, 0.92); - border-radius: vars.$border-radius; - padding: 4px; - margin-bottom: vars.$space-m2; + + // When week-tabs sits directly above the column-header row inside the + // sticky card (Angular parity layout), cancel the bottom margin so the + // two rows sit flush together. + &:has(+ .schedule-col-header), + &:has(+ .schedule-direction-switch + .schedule-col-header) { + margin-bottom: 0; + } &__nav { - background: transparent; + flex: 0 0 50px; + width: 50px; + max-height: 48px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: colors.$blue-extra-light; border: none; - color: colors.$light-gray; - font-size: fonts.$font-size-xl; - width: 28px; + border-bottom: 1px solid colors.$border; + color: colors.$blue; cursor: pointer; - border-radius: vars.$border-radius; + // Override the global `button { min-height: 35px }` so we can hit + // the Angular 48px row height precisely. + min-height: 0; + height: 48px; + line-height: 0; + transition: opacity 0.15s; + + svg { display: block; } &:hover:not(:disabled) { - background: rgba(0, 0, 0, 0.04); - color: colors.$blue-dark; + background: colors.$blue-icon; } + &:disabled { - opacity: 0.3; + opacity: 0.5; cursor: not-allowed; } + + &--prev { + border-top-left-radius: vars.$border-radius; + } + + &--next { + border-top-right-radius: vars.$border-radius; + } } &__list { display: flex; - gap: 2px; flex: 1; - overflow-x: auto; + min-width: 0; + overflow: hidden; } &__tab { flex: 1; min-width: 0; - padding: vars.$space-s2 vars.$space-m2; - background: transparent; + padding: 0 vars.$space-m2; + height: 48px; + max-height: 48px; + // Override the global `button { min-height: 35px }`. + min-height: 0; + background: colors.$blue-extra-light; border: none; - font-size: 13px; + border-right: 1px solid colors.$border; + border-bottom: 1px solid colors.$border; + border-radius: 0; + font-size: 12px; + font-weight: fonts.$font-medium; color: colors.$blue; cursor: pointer; - border-radius: vars.$border-radius; white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; transition: background 0.2s; - &:hover { background: colors.$blue-extra-light; } + &:hover:not(:disabled):not(&--active) { + background: colors.$blue-icon; + } &--active { background: colors.$white; + // Hide the bottom border so the active tab visually merges into + // the column-header / table below it (Angular parity). + border-bottom-color: colors.$white; color: colors.$blue-dark; font-weight: fonts.$font-bold; - box-shadow: inset 0 -2px 0 colors.$blue; cursor: default; } diff --git a/src/features/schedule/components/WeekTabs.tsx b/src/features/schedule/components/WeekTabs.tsx index 4301e9ac..cbe6035f 100644 --- a/src/features/schedule/components/WeekTabs.tsx +++ b/src/features/schedule/components/WeekTabs.tsx @@ -133,7 +133,9 @@ export const WeekTabs: FC = ({ selectedMonday, onNavigate }) => { onClick={() => setPage((p) => Math.max(0, p - 1))} aria-label={t("SHARED.A11Y-PREV-PAGE")} > - ‹ +
{activeSlice.map((w) => { @@ -174,7 +176,9 @@ export const WeekTabs: FC = ({ selectedMonday, onNavigate }) => { onClick={() => setPage((p) => Math.min(activeTotalPages - 1, p + 1))} aria-label={t("SHARED.A11Y-NEXT-PAGE")} > - › + ); diff --git a/src/features/schedule/dateLabels.test.ts b/src/features/schedule/dateLabels.test.ts index 4af2b30c..f5f00b4a 100644 --- a/src/features/schedule/dateLabels.test.ts +++ b/src/features/schedule/dateLabels.test.ts @@ -15,18 +15,28 @@ describe("4.1.9-R: formatScheduleDateRangeWithCurrentWeek", () => { ).toBe("Текущая неделя"); }); - it("returns dd.MM.yyyy-dd.MM.yyyy for other ranges", () => { + it("returns dd.MM.yyyy-dd.MM.yyyy for ranges that don't contain today", () => { const t = (k: string) => k; expect( formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 18), new Date(2026, 4, 24), t), ).toBe("18.05.2026-24.05.2026"); }); - it("returns dd.MM.yyyy-dd.MM.yyyy for partial current week", () => { - const t = (k: string) => k; + it("returns 'Текущая неделя' for partial current week containing today (matches Angular)", () => { + // today = 2026-05-15 (Fri); range 2026-05-13 .. 2026-05-17 contains today. + // Angular's CalendarInputWeekComponent uses `from <= today <= to`. + const t = (k: string) => (k === "SCHEDULE.CURRENT-WEEK" ? "Текущая неделя" : k); expect( formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 13), new Date(2026, 4, 17), t), - ).toBe("13.05.2026-17.05.2026"); + ).toBe("Текущая неделя"); + }); + + it("returns 'Текущая неделя' for clamped popular-click range (today-1 .. Sun)", () => { + // Popular-click on Schedule clamps `from` to today−1 = 2026-05-14. + const t = (k: string) => (k === "SCHEDULE.CURRENT-WEEK" ? "Текущая неделя" : k); + expect( + formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 14), new Date(2026, 4, 17), t), + ).toBe("Текущая неделя"); }); it("returns empty string for null inputs", () => { diff --git a/src/features/schedule/dateLabels.ts b/src/features/schedule/dateLabels.ts index 1cc33ca8..cbb8e605 100644 --- a/src/features/schedule/dateLabels.ts +++ b/src/features/schedule/dateLabels.ts @@ -1,6 +1,11 @@ /** * Schedule range-calendar label substitution per TZ §4.1.9 Table 14. - * Current week Mon-Sun → "Текущая неделя", otherwise dd.MM.yyyy-dd.MM.yyyy. + * Any range containing today → "Текущая неделя", otherwise dd.MM.yyyy-dd.MM.yyyy. + * + * Matches Angular `CalendarInputWeekComponent.getDateString()`: substitutes + * the label whenever `from <= today <= to`, not only on an exact Mon-Sun + * match. This covers the popular-click case where the start page clamps + * the outbound `from` to today−1 to stay inside Schedule's [-1, +330] window. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -12,14 +17,6 @@ function toYmd(d: Date): string { return `${day}.${month}.${d.getFullYear()}`; } -function mondayOfWeek(base: Date): Date { - const d = new Date(base); - d.setHours(0, 0, 0, 0); - const offset = (d.getDay() + 6) % 7; - d.setDate(d.getDate() - offset); - return d; -} - export function formatScheduleDateRangeWithCurrentWeek( dateFrom: Date | null | undefined, dateTo: Date | null | undefined, @@ -28,14 +25,11 @@ export function formatScheduleDateRangeWithCurrentWeek( if (!dateFrom || !dateTo) return ""; const today = new Date(); today.setHours(0, 0, 0, 0); - const thisMon = mondayOfWeek(today); - const thisSun = new Date(thisMon); - thisSun.setDate(thisSun.getDate() + 6); const from = new Date(dateFrom); from.setHours(0, 0, 0, 0); const to = new Date(dateTo); to.setHours(0, 0, 0, 0); - if (from.getTime() === thisMon.getTime() && to.getTime() === thisSun.getTime()) { + if (from.getTime() <= today.getTime() && today.getTime() <= to.getTime()) { return t("SCHEDULE.CURRENT-WEEK"); } return `${toYmd(from)}-${toYmd(to)}`; diff --git a/src/features/schedule/extractSimpleFlights.ts b/src/features/schedule/extractSimpleFlights.ts new file mode 100644 index 00000000..a549319d --- /dev/null +++ b/src/features/schedule/extractSimpleFlights.ts @@ -0,0 +1,54 @@ +/** + * Convert the mixed `IFlight[]` schedule-search response into a flat + * `ISimpleFlight[]` for rendering. + * + * Connecting flights are folded into a synthetic MultiLeg shape so the + * existing FlightCard can render them with combined leg numbers, both + * airline logos, and the total flying time — matching Angular's + * `schedule-list-flight-header` for connecting flights. + * + * @module + */ + +import type { FlightStatus, IFlightLeg } from "@/features/online-board/types.js"; +import type { ISimpleFlight } from "./types.js"; + +export function extractSimpleFlights( + flights: Array<{ routeType: string }>, +): ISimpleFlight[] { + const out: ISimpleFlight[] = []; + for (const f of flights) { + if (f.routeType === "Direct" || f.routeType === "MultiLeg") { + out.push(f as unknown as ISimpleFlight); + continue; + } + if (f.routeType === "Connecting") { + const conn = f as unknown as { + flights: ISimpleFlight[]; + flyingTime: string; + status: FlightStatus; + }; + const first = conn.flights[0]; + if (!first) continue; + const allLegs: IFlightLeg[] = []; + for (const child of conn.flights) { + if (child.routeType === "Direct") allLegs.push(child.leg); + else allLegs.push(...child.legs); + } + const synthetic = { + routeType: "MultiLeg", + flightId: first.flightId, + flyingTime: conn.flyingTime, + operatingBy: first.operatingBy, + id: conn.flights.map((c) => c.id).join("+"), + status: conn.status, + legs: allLegs, + // Carry through the original child flight numbers so the header + // can display 'SU 6188, SU 6233'. + _childFlightIds: conn.flights.map((c) => c.flightId), + } as unknown as ISimpleFlight; + out.push(synthetic); + } + } + return out; +} diff --git a/src/shared/airportUrls.ts b/src/shared/airportUrls.ts new file mode 100644 index 00000000..11ba260c --- /dev/null +++ b/src/shared/airportUrls.ts @@ -0,0 +1,16 @@ +/** + * Airport IATA → official site URL map. Mirrors Angular's + * `ClientApp/src/app/shared/services/airports-data.service.ts`. + * Used by the station-display terminal link. + */ +export const airportUrls: Readonly> = { + SVO: "https://www.svo.aero/ru/main", + VKO: "http://www.vnukovo.ru/", + DME: "https://www.dme.ru/", + ZIA: "http://www.zia.aero/", +}; + +export function airportUrl(airportCode: string | undefined | null): string | undefined { + if (!airportCode) return undefined; + return airportUrls[airportCode]; +} diff --git a/src/ui/city-autocomplete/CityAutocomplete.scss b/src/ui/city-autocomplete/CityAutocomplete.scss index 6008e109..dfd0d046 100644 --- a/src/ui/city-autocomplete/CityAutocomplete.scss +++ b/src/ui/city-autocomplete/CityAutocomplete.scss @@ -30,17 +30,27 @@ text-overflow: ellipsis; } + // Angular `city-autocomplete__input` measured height = 46px with + // 16px font-size (`Расписание рейсов` sidebar on the live site). + // Previously this was 38px / default font-size which looked noticeably + // shorter than Angular's pill. + $city-input-h: 46px; + &__input { display: flex; flex-direction: row; position: relative; align-items: center; width: 100%; + height: $city-input-h; box-shadow: 0 0 0 1px colors.$border-input; border-radius: vars.$border-radius; .p-autocomplete { flex: 1; + display: flex; + align-items: center; + height: 100%; } // Reset the inner PrimeReact input's native border — the outer @@ -49,6 +59,9 @@ input.p-inputtext { border: none !important; box-shadow: none !important; + height: 100%; + font-size: fonts.$font-size-l; // 16px, matches Angular + padding: 0 vars.$space-l; } // Also drop PrimeReact's blue focus shadow on the inner input @@ -61,7 +74,7 @@ .button-clear { display: none; width: 32px; - height: 38px; + height: $city-input-h; border: none; background: transparent; cursor: pointer; @@ -91,7 +104,7 @@ &__search-button { width: 38px !important; min-width: 38px; - height: 38px; + height: $city-input-h; border-radius: 0 vars.$border-radius vars.$border-radius 0 !important; border: none !important; border-left: 1px solid white !important; diff --git a/src/ui/flights/FlightCard.scss b/src/ui/flights/FlightCard.scss index 8dbcefa7..4478d8f3 100644 --- a/src/ui/flights/FlightCard.scss +++ b/src/ui/flights/FlightCard.scss @@ -62,12 +62,48 @@ grid-template-columns: 80px 120px 100px minmax(45px, 240px) 100px 100px minmax(45px, 240px) 10px; gap: 0 vars.$space-l; + padding: vars.$space-xl; + } + + // Schedule row typography — values taken from the live Angular page + // (computed styles on `list-scheduled-flight schedule-list-flight-header`, + // measured 2026-04-23): + // flight number 18px / 400 (regular) + // time 30px / 300 (light) + // station city 14px / 400 (regular) + // station term 12px / 400 (regular, underlined) + // duration text 12px / 400 (regular) + &--schedule .flight-card__number { + font-size: fonts.$font-size-xl; + font-weight: fonts.$font-regular; + line-height: 1.25; + } + + &--schedule .flight-card__time .time-group__scheduled, + &--schedule .flight-card__time .time-group__actual { + font-size: fonts.$font-size-xxl; + font-weight: fonts.$font-light; + line-height: 1.15; + } + + &--schedule .flight-card__station .station__city--bold { + font-size: fonts.$font-size-m; + font-weight: fonts.$font-regular; + } + + &--schedule .flight-card__station .station__terminal { + font-size: fonts.$font-size-s; + } + + &--schedule .flight-card__duration { + font-size: fonts.$font-size-s; } &__number { font-weight: fonts.$font-medium; color: colors.$text-color; font-size: fonts.$font-size-m; + line-height: 1.2; } &__aircraft { @@ -120,6 +156,61 @@ } } + // Angular hides the row-expand chevron unless the row is hovered or + // already expanded (see schedule-list-flight-header.scss + // `.arrow-icon { display: none } :host:hover .arrow-icon { display: initial }`). + &--schedule .flight-card__chevron { + visibility: hidden; + } + &--schedule .flight-card__row:hover .flight-card__chevron, + &--schedule.flight-card--expanded .flight-card__chevron { + visibility: visible; + } + + // Angular renders a compact `transfer-inline` bar below the collapsed + // schedule row for connecting flights. The bar is offset left by the + // number + operator columns (`margin-left: 80px + 120px + 2 * $space-xl` + // per schedule-list-flight-header.scss) and sits in a thin pill with + // the dumbbell transfer icon tinted orange. + &__transfer { + display: flex; + align-items: center; + gap: vars.$space-s; + // 80 (number) + 120 (logos) + 20 (left pad) + 20 (gap) = 240px + margin: 0 vars.$space-xl vars.$space-m 240px; + padding: 6px vars.$space-m; + background: colors.$white; + border: 1px solid colors.$border; + border-radius: vars.$border-radius; + font-size: fonts.$font-size-s; + color: colors.$text-color; + width: fit-content; + max-width: calc(100% - 240px - #{vars.$space-xl}); + } + + &__transfer-icon { + display: inline-flex; + align-items: center; + color: #f78c2f; // Aeroflot orange transfer-dot tint + } + + &__transfer-label { + font-weight: fonts.$font-regular; + color: colors.$text-color; + } + + &__transfer-dash { + color: colors.$light-gray; + } + + &__transfer-stations { + color: colors.$text-color; + } + + &__transfer-airport { + color: colors.$blue; + } + &__inline-actions { display: flex; align-items: center; diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index 1af1f95f..25e9c154 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -355,7 +355,19 @@ export const FlightCard: FC = ({ : {})} >
-
{flightNumber}
+ {/* Angular's `schedule-list-flight-header` stacks each leg's + flight number on its own line (e.g. "SU 6951," / "SU 6345") + in the schedule row. Outside schedule mode we keep the + existing single-line presentation. */} + {direction === "schedule" && childFlightIds && childFlightIds.length > 1 ? ( + childFlightIds.map((id, i) => ( +
+ {id.carrier} {id.flightNumber}{id.suffix ?? ""}{i < childFlightIds.length - 1 ? "," : ""} +
+ )) + ) : ( +
{flightNumber}
+ )} {expanded && flight.routeType === "Direct" && aircraftName && (
{aircraftName}
)} @@ -480,6 +492,56 @@ export const FlightCard: FC = ({ )}
+ {/* Angular `schedule-list-flight-header` renders a compact transfer + bar below the row when the flight is collapsed and connecting + (`*ngIf="!flight.expanded && flight.boardings >= 1"`). */} + {direction === "schedule" && !expanded && flight.routeType !== "Direct" && + flight.legs.length > 1 && ( +
+ + + {t( + flight.legs.length > 2 + ? "SHARED.INTERMEDIATE-LANDING-PLURAL-OTHER" + : "SHARED.FLIGHT-TRANSFER-PLURAL-ONE", + )} + +  —  + + {flight.legs.slice(0, -1).map((l, i) => { + const s = l.arrival.scheduled; + const terminal = l.arrival.terminal; + const airportWithTerminal = terminal + ? `${s.airport} - ${terminal}` + : s.airport; + return ( + + {i > 0 ? ", " : ""} + {s.city} + {s.airport ? ( + <> + {", "} + + {airportWithTerminal} + + + ) : null} + + ); + })} + +
+ )} + {expandable && expanded && renderExpandedBody && (
to the airport's site (SVO, + // VKO, …). Match Angular's terminal-link blue hover state; the + // dotted underline keeps it visually distinct from a full-blue + // CTA without losing its "clickable" affordance. + &--link { + color: colors.$blue; + cursor: pointer; + + &:hover { + color: colors.$blue--hover; + } + } } &--city-first { diff --git a/src/ui/flights/StationDisplay.tsx b/src/ui/flights/StationDisplay.tsx index c7afe58f..f54c81eb 100644 --- a/src/ui/flights/StationDisplay.tsx +++ b/src/ui/flights/StationDisplay.tsx @@ -1,5 +1,6 @@ -import type { FC } from "react"; +import type { FC, MouseEvent } from "react"; import { useCityName } from "@/shared/hooks/useDictionaries.js"; +import { airportUrl } from "@/shared/airportUrls.js"; import "./StationDisplay.scss"; export interface StationDisplayProps { @@ -32,14 +33,49 @@ export const StationDisplay: FC = ({ }) => { const resolvedCity = cityName ?? useCityName(airportCode); const terminalLine = [airportName, terminal].filter(Boolean).join(" — "); + const url = airportUrl(airportCode); + + // Clicking the airport link should NOT toggle the parent flight row. + const stopBubble = (e: MouseEvent): void => { + e.stopPropagation(); + }; + + // Airport tooltip mirrors Angular's `terminal-link` pTooltip — the + // full "Airport name — Terminal N" (e.g. "Шереметьево — B"). The + // city tooltip just shows the city name itself, matching station's + // `[tooltip]="city"` ellipsis helper. + const airportTooltip = terminalLine || airportName || undefined; + const cityTooltip = resolvedCity; + + const terminalEl = terminalLine ? ( + url ? ( + + {terminalLine} + + ) : ( + + {terminalLine} + + ) + ) : null; if (cityFirst) { return (
- {resolvedCity} - {terminalLine ? ( - {terminalLine} - ) : null} + + {resolvedCity} + + {terminalEl}
); } @@ -50,7 +86,9 @@ export const StationDisplay: FC = ({ {airportName ? ( {airportName} ) : null} - {resolvedCity} + + {resolvedCity} +
); }; diff --git a/tests/ci/fixtures/jenkins-failure-flow.json b/tests/ci/fixtures/jenkins-failure-flow.json new file mode 100644 index 00000000..68ca4ff9 --- /dev/null +++ b/tests/ci/fixtures/jenkins-failure-flow.json @@ -0,0 +1,15 @@ +{ + "trigger_response": { + "status": 201, + "headers": { + "Location": "http://jenkins.test/queue/item/78/" + } + }, + "queue_polls": [ + {"status": 200, "body": {"executable": {"number": 43, "url": "http://jenkins.test/job/Aeroflot2/job/Flights-Front-Dev/43/"}}} + ], + "build_polls": [ + {"status": 200, "body": {"building": true, "result": null, "number": 43}}, + {"status": 200, "body": {"building": false, "result": "FAILURE", "number": 43}} + ] +} diff --git a/tests/ci/fixtures/jenkins-success-flow.json b/tests/ci/fixtures/jenkins-success-flow.json new file mode 100644 index 00000000..ac181f58 --- /dev/null +++ b/tests/ci/fixtures/jenkins-success-flow.json @@ -0,0 +1,18 @@ +{ + "trigger_response": { + "status": 201, + "headers": { + "Location": "http://jenkins.test/queue/item/77/" + } + }, + "queue_polls": [ + {"status": 200, "body": {"why": "in queue", "executable": null}}, + {"status": 200, "body": {"why": "in queue", "executable": null}}, + {"status": 200, "body": {"executable": {"number": 42, "url": "http://jenkins.test/job/Aeroflot2/job/Flights-Front-Dev/42/"}}} + ], + "build_polls": [ + {"status": 200, "body": {"building": true, "result": null, "number": 42}}, + {"status": 200, "body": {"building": true, "result": null, "number": 42}}, + {"status": 200, "body": {"building": false, "result": "SUCCESS", "number": 42}} + ] +} diff --git a/tests/ci/test-deploy-container.sh b/tests/ci/test-deploy-container.sh new file mode 100755 index 00000000..c041e8b7 --- /dev/null +++ b/tests/ci/test-deploy-container.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")/../.." && pwd)/scripts/ci/deploy-container.sh" +[ -x "$SCRIPT" ] || { echo "FAIL: $SCRIPT not executable"; exit 1; } + +assert_contains() { + local haystack="$1" needle="$2" + case "$haystack" in + *"$needle"*) ;; + *) echo "FAIL: expected '$needle' in:"; echo "$haystack"; exit 1 ;; + esac +} + +assert_order() { + local haystack="$1" first="$2" second="$3" + local pos1 pos2 + pos1=$(printf '%s' "$haystack" | grep -nF "$first" | head -1 | cut -d: -f1) + pos2=$(printf '%s' "$haystack" | grep -nF "$second" | head -1 | cut -d: -f1) + if [ -z "$pos1" ] || [ -z "$pos2" ] || [ "$pos1" -ge "$pos2" ]; then + echo "FAIL: expected '$first' (line $pos1) before '$second' (line $pos2)" + echo "$haystack" + exit 1 + fi +} + +export GITHUB_SHA=abcdef1234567890 +export FLIGHTS_WEB_PORT=8081 + +# --- swap --- +out=$("$SCRIPT" --dry-run swap) +# Order matters: tag previous before tagging current; remove old container before run new +assert_order "$out" "tag flights-web:current flights-web:previous" "tag flights-web:abcdef1 flights-web:current" +assert_contains "$out" "stop flights-web" +assert_contains "$out" "rm flights-web" +assert_order "$out" "rm flights-web" "run -d --name flights-web" +assert_contains "$out" "127.0.0.1:8081:8080" +assert_contains "$out" "flights-web:current" + +# --- rollback --- +out=$("$SCRIPT" --dry-run rollback) +assert_contains "$out" "stop flights-web" +assert_contains "$out" "rm flights-web" +assert_contains "$out" "flights-web:previous" +# After running previous, current alias should be repointed +assert_order "$out" "run -d --name flights-web" "tag flights-web:previous flights-web:current" + +# --- bad subcommand --- +if "$SCRIPT" --dry-run foo 2>/dev/null; then + echo "FAIL: expected unknown subcommand to error"; exit 1 +fi + +echo "PASS: deploy-container.sh" diff --git a/tests/ci/test-jenkins-trigger.sh b/tests/ci/test-jenkins-trigger.sh new file mode 100755 index 00000000..b4c1780c --- /dev/null +++ b/tests/ci/test-jenkins-trigger.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SCRIPT="$ROOT/scripts/ci/jenkins-trigger-and-wait.sh" +[ -x "$SCRIPT" ] || { echo "FAIL: $SCRIPT not executable"; exit 1; } + +# Mock-mode tests need jq — bail with a useful message if unavailable. +command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not installed"; exit 0; } + +# --- success path --- +if ! "$SCRIPT" --mock-mode "$ROOT/tests/ci/fixtures/jenkins-success-flow.json" 2>&1 | tee /tmp/jenkins-test.log; then + echo "FAIL: success fixture should exit 0" + exit 1 +fi +grep -q "build #42 SUCCESS" /tmp/jenkins-test.log || { echo "FAIL: expected 'build #42 SUCCESS'"; exit 1; } + +# --- failure path --- +if "$SCRIPT" --mock-mode "$ROOT/tests/ci/fixtures/jenkins-failure-flow.json" 2>&1 | tee /tmp/jenkins-test.log; then + echo "FAIL: failure fixture should exit non-zero" + exit 1 +fi +grep -q "FAILURE" /tmp/jenkins-test.log || { echo "FAIL: expected 'FAILURE' in output"; exit 1; } + +# --- bad usage --- +if "$SCRIPT" 2>/dev/null; then + echo "FAIL: expected usage error" + exit 1 +fi + +echo "PASS: jenkins-trigger-and-wait.sh" diff --git a/tests/ci/test-notify-telegram.sh b/tests/ci/test-notify-telegram.sh new file mode 100755 index 00000000..ef678387 --- /dev/null +++ b/tests/ci/test-notify-telegram.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Test: scripts/ci/notify-telegram.sh in --dry-run mode emits the right payloads. +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")/../.." && pwd)/scripts/ci/notify-telegram.sh" +[ -x "$SCRIPT" ] || { echo "FAIL: $SCRIPT not executable"; exit 1; } + +# Required env for non-dry-run; test still sets them so the script doesn't bail early. +export TELEGRAM_BOT_TOKEN="test-token" +export TELEGRAM_CHAT_ID="123" +export GITHUB_REPOSITORY="gnezim/flights-web" +export GITHUB_RUN_ID="42" +export GITHUB_SERVER_URL="https://git.gnerim.ru" +export GITHUB_SHA="abc1234567890" +export GITHUB_WORKFLOW="ci-deploy" + +assert_contains() { + local haystack="$1" needle="$2" + case "$haystack" in + *"$needle"*) ;; + *) echo "FAIL: expected to find '$needle' in:"; echo "$haystack"; exit 1 ;; + esac +} + +# --- start --- +out=$("$SCRIPT" --dry-run start ci-deploy) +assert_contains "$out" "🚀 ci-deploy started" +assert_contains "$out" "abc1234" +assert_contains "$out" "https://git.gnerim.ru/gnezim/flights-web/actions/runs/42" + +# --- ok --- +out=$("$SCRIPT" --dry-run ok ci-deploy) +assert_contains "$out" "✅ ci-deploy passed" + +# --- fail with extra context --- +out=$("$SCRIPT" --dry-run fail ci-deploy "Run Playwright e2e") +assert_contains "$out" "❌ ci-deploy FAILED" +assert_contains "$out" "Run Playwright e2e" + +# --- missing env should error in non-dry-run --- +unset TELEGRAM_BOT_TOKEN +if "$SCRIPT" ok ci-deploy 2>/dev/null; then + echo "FAIL: expected error when TELEGRAM_BOT_TOKEN missing" + exit 1 +fi + + +# --- fail with log tail --- +TMPLOG=$(mktemp) +printf 'line1\nline2\nline3\n' > "$TMPLOG" +out=$("$SCRIPT" --dry-run fail ci-deploy "Run Playwright e2e" "$TMPLOG") +assert_contains "$out" "last 3 lines" +assert_contains "$out" "line1" +assert_contains "$out" "line3" +rm -f "$TMPLOG" + +# --- fail with missing log file: should still print message, no crash --- +out=$("$SCRIPT" --dry-run fail ci-deploy "Build" "/nonexistent/log") +assert_contains "$out" "❌ ci-deploy FAILED" + +echo "PASS: notify-telegram.sh" diff --git a/tests/ci/test-wait-for-url.sh b/tests/ci/test-wait-for-url.sh new file mode 100755 index 00000000..ce49d66d --- /dev/null +++ b/tests/ci/test-wait-for-url.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT="$(cd "$(dirname "$0")/../.." && pwd)/scripts/ci/wait-for-url.sh" +[ -x "$SCRIPT" ] || { echo "FAIL: $SCRIPT not executable"; exit 1; } + +# Spin up a tiny HTTP server on a random free port. +PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()') +TMPDIR=$(mktemp -d) +echo "ok" > "$TMPDIR/index.html" +( cd "$TMPDIR" && python3 -m http.server "$PORT" >/dev/null 2>&1 ) & +SERVER_PID=$! +trap "kill $SERVER_PID 2>/dev/null; rm -rf $TMPDIR" EXIT + +# Wait for server to come up +for _ in 1 2 3 4 5 6 7 8 9 10; do + if curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then break; fi + sleep 0.2 +done + +# 200 case — should pass quickly +"$SCRIPT" "http://127.0.0.1:$PORT/" 5 1 || { echo "FAIL: expected 200 to succeed"; exit 1; } + +# 404 case — script should fail (curl -f returns non-zero on 4xx) +if "$SCRIPT" "http://127.0.0.1:$PORT/no-such-path" 3 1 2>/dev/null; then + echo "FAIL: expected 404 to fail"; exit 1 +fi + +# Network failure case — wrong port +if "$SCRIPT" "http://127.0.0.1:1/" 2 1 2>/dev/null; then + echo "FAIL: expected unreachable URL to fail"; exit 1 +fi + +# Bad usage +if "$SCRIPT" 2>/dev/null; then + echo "FAIL: expected usage error"; exit 1 +fi + +echo "PASS: wait-for-url.sh" diff --git a/tests/e2e/breadcrumbs-parity.spec.ts b/tests/e2e/breadcrumbs-parity.spec.ts index 9c06c0f6..7b3040eb 100644 --- a/tests/e2e/breadcrumbs-parity.spec.ts +++ b/tests/e2e/breadcrumbs-parity.spec.ts @@ -1,4 +1,5 @@ -import { test, expect, type Page } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; +import type { Page } from "@playwright/test"; // Angular's breadcrumb trail (audited live on flights.test.aeroflot.ru): // /schedule → [Главная] @@ -143,7 +144,7 @@ const cases: { name: string; url: string; expected: { text: string; href: string test.describe("Breadcrumb parity with Angular", () => { for (const c of cases) { - test(c.name, async ({ page }) => { + test(c.name, async ({ page, consoleMessages }) => { await page.goto(c.url); await expect(page.getByTestId("breadcrumbs")).toBeVisible({ timeout: 15000 }); // Poll on the full items array — the leaf depends on dictionaries diff --git a/tests/e2e/fixtures/console-allowlist.json b/tests/e2e/fixtures/console-allowlist.json new file mode 100644 index 00000000..b5b010c3 --- /dev/null +++ b/tests/e2e/fixtures/console-allowlist.json @@ -0,0 +1,3 @@ +{ + "patterns": [] +} diff --git a/tests/e2e/fixtures/console-gate.ts b/tests/e2e/fixtures/console-gate.ts new file mode 100644 index 00000000..8c256515 --- /dev/null +++ b/tests/e2e/fixtures/console-gate.ts @@ -0,0 +1,72 @@ +import { test as base, expect } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +interface AllowlistEntry { + pattern: string; + reason: string; +} + +interface Allowlist { + patterns: AllowlistEntry[]; +} + +const FIXTURE_DIR = path.dirname(fileURLToPath(import.meta.url)); +const ALLOWLIST_PATH = path.join(FIXTURE_DIR, "console-allowlist.json"); + +function loadAllowlist(): RegExp[] { + const raw = fs.readFileSync(ALLOWLIST_PATH, "utf8"); + const parsed: Allowlist = JSON.parse(raw); + for (const entry of parsed.patterns) { + if (!entry.reason || entry.reason.trim() === "") { + throw new Error( + `console-allowlist.json: pattern ${JSON.stringify(entry.pattern)} has no reason` + ); + } + } + return parsed.patterns.map((e) => new RegExp(e.pattern)); +} + +const allowlist = loadAllowlist(); + +function isAllowed(message: string): boolean { + return allowlist.some((re) => re.test(message)); +} + +interface ConsoleGateFixtures { + consoleMessages: string[]; +} + +export const test = base.extend({ + consoleMessages: async ({ page }, use, testInfo) => { + const messages: string[] = []; + page.on("console", (msg) => { + const type = msg.type(); + if (type !== "error" && type !== "warning") return; + const text = `[${type}] ${msg.text()}`; + if (isAllowed(text)) return; + messages.push(text); + }); + page.on("pageerror", (err) => { + const text = `[pageerror] ${err.message}`; + if (!isAllowed(text)) messages.push(text); + }); + + await use(messages); + + if (messages.length > 0) { + testInfo.attachments.push({ + name: "console-violations.txt", + contentType: "text/plain", + body: Buffer.from(messages.join("\n"), "utf8"), + }); + throw new Error( + `Console gate: ${messages.length} disallowed message(s):\n` + + messages.map((m) => ` ${m}`).join("\n") + ); + } + }, +}); + +export { expect }; diff --git a/tests/e2e/flights-map.spec.ts b/tests/e2e/flights-map.spec.ts index a8fb2b28..675f9c63 100644 --- a/tests/e2e/flights-map.spec.ts +++ b/tests/e2e/flights-map.spec.ts @@ -1,8 +1,9 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; test.describe("Flights Map", () => { test("/ru/flights-map renders or shows feature-flag disabled message", async ({ page, + consoleMessages, }) => { await page.goto("/ru/flights-map"); await page.waitForLoadState("domcontentloaded"); diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index a4fdb4dc..0be052ed 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -1,8 +1,9 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; test.describe("Cross-feature navigation", () => { test("locale switching: /ru/onlineboard -> /en/onlineboard shows English content", async ({ page, + consoleMessages, }) => { // Start on Russian online board await page.goto("/ru/onlineboard"); @@ -23,7 +24,7 @@ test.describe("Cross-feature navigation", () => { expect(page.url()).toMatch(/\/en(-[a-z]+)?\/onlineboard/); }); - test("error page: /error/404 renders 404 content", async ({ page }) => { + test("error page: /error/404 renders 404 content", async ({ page, consoleMessages }) => { // Navigate to a working page first, then client-side navigate to the error // page. Direct URL navigation to /error/404 renders blank because the // error route is outside [lang]/layout.tsx and SSR produces empty output. @@ -42,6 +43,7 @@ test.describe("Cross-feature navigation", () => { test("error page: /error/500 renders server error content", async ({ page, + consoleMessages, }) => { // Navigate to a working page first, then client-side navigate to the error // page (same reason as the 404 test above). @@ -57,7 +59,7 @@ test.describe("Cross-feature navigation", () => { await expect(page.locator(".error-page__code")).toHaveText("500", { timeout: 10000 }); }); - test("unknown route: /ru/nonexistent does not crash", async ({ page }) => { + test("unknown route: /ru/nonexistent does not crash", async ({ page, consoleMessages }) => { const response = await page.goto("/ru/nonexistent"); await page.waitForLoadState("domcontentloaded"); diff --git a/tests/e2e/online-board.spec.ts b/tests/e2e/online-board.spec.ts index acb30671..74cd1616 100644 --- a/tests/e2e/online-board.spec.ts +++ b/tests/e2e/online-board.spec.ts @@ -1,8 +1,9 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; test.describe("Online Board", () => { test("/ru/onlineboard renders the start page with search form", async ({ page, + consoleMessages, }) => { await page.goto("/ru/onlineboard"); await page.waitForLoadState("domcontentloaded"); @@ -18,6 +19,7 @@ test.describe("Online Board", () => { test("filter has accordion with Flight Number and Route tabs", async ({ page, + consoleMessages, }) => { await page.goto("/ru/onlineboard"); await page.waitForLoadState("domcontentloaded"); @@ -36,7 +38,7 @@ test.describe("Online Board", () => { ).toBeVisible(); }); - test("clicking Flight Number tab switches to flight form", async ({ page }) => { + test("clicking Flight Number tab switches to flight form", async ({ page, consoleMessages }) => { await page.goto("/ru/onlineboard"); await page.waitForLoadState("domcontentloaded"); @@ -63,6 +65,7 @@ test.describe("Online Board", () => { test("search form has route inputs, date picker, and submit button", async ({ page, + consoleMessages, }) => { await page.goto("/ru/onlineboard"); await page.waitForLoadState("domcontentloaded"); @@ -86,7 +89,7 @@ test.describe("Online Board", () => { await expect(page.locator('[data-testid="search-submit"]')).toBeVisible(); }); - test("flight number clear button clears the input", async ({ page }) => { + test("flight number clear button clears the input", async ({ page, consoleMessages }) => { await page.goto("/ru/onlineboard"); await page.waitForLoadState("domcontentloaded"); @@ -107,7 +110,7 @@ test.describe("Online Board", () => { await expect(page.locator('[data-testid="flight-number-input"]')).toHaveValue(""); }); - test("route tab has swap button and time selector", async ({ page }) => { + test("route tab has swap button and time selector", async ({ page, consoleMessages }) => { await page.goto("/ru/onlineboard"); await page.waitForLoadState("domcontentloaded"); @@ -125,7 +128,7 @@ test.describe("Online Board", () => { await expect(page.locator('[data-testid="time-selector"]')).toBeVisible(); }); - test("breadcrumbs are visible on start page", async ({ page }) => { + test("breadcrumbs are visible on start page", async ({ page, consoleMessages }) => { await page.goto("/ru/onlineboard"); await page.waitForLoadState("domcontentloaded"); @@ -137,7 +140,7 @@ test.describe("Online Board", () => { }); // FeedbackButton component exists but is not wired into OnlineBoardStartPage yet - test.fixme("feedback button is visible", async ({ page }) => { + test.fixme("feedback button is visible", async ({ page, consoleMessages }) => { await page.goto("/ru/onlineboard"); await page.waitForLoadState("domcontentloaded"); @@ -150,6 +153,7 @@ test.describe("Online Board", () => { test("/ru/onlineboard/flight/SU0100-20260415 renders the flight search page", async ({ page, + consoleMessages, }) => { await page.goto("/ru/onlineboard/flight/SU0100-20260415"); await page.waitForLoadState("domcontentloaded"); @@ -163,6 +167,7 @@ test.describe("Online Board", () => { test("/ru/onlineboard/departure/SVO-20260415 renders the departure search page", async ({ page, + consoleMessages, }) => { await page.goto("/ru/onlineboard/departure/SVO-20260415"); await page.waitForLoadState("domcontentloaded"); @@ -173,6 +178,7 @@ test.describe("Online Board", () => { test("/ru/onlineboard/route/SVO-LED-20260415 renders the route search page", async ({ page, + consoleMessages, }) => { await page.goto("/ru/onlineboard/route/SVO-LED-20260415"); await page.waitForLoadState("domcontentloaded"); @@ -183,6 +189,7 @@ test.describe("Online Board", () => { test("flight details page at /ru/onlineboard/SU0100-20260415 renders", async ({ page, + consoleMessages, }) => { await page.goto("/ru/onlineboard/SU0100-20260415"); await page.waitForLoadState("domcontentloaded"); @@ -193,7 +200,7 @@ test.describe("Online Board", () => { // Requires live API (city autocomplete + calendar days). // Skipped when WAF blocks flights.test.aeroflot.ru. - test.skip("route search via form navigates to correct URL", async ({ page }) => { + test.skip("route search via form navigates to correct URL", async ({ page, consoleMessages }) => { await page.goto("/ru/onlineboard"); await page.waitForLoadState("networkidle"); @@ -229,6 +236,7 @@ test.describe("Online Board", () => { test("route search results page hydrates filter from URL params", async ({ page, + consoleMessages, }) => { await page.goto("/ru/onlineboard/route/MOW-KUF-20260416"); await page.waitForLoadState("networkidle"); @@ -249,6 +257,7 @@ test.describe("Online Board", () => { // Skipped when WAF blocks flights.test.aeroflot.ru. test.skip("route search results page shows calendar strip with day numbers", async ({ page, + consoleMessages, }) => { await page.goto("/ru/onlineboard/route/MOW-KUF-20260416"); await page.waitForLoadState("networkidle"); @@ -269,7 +278,7 @@ test.describe("Online Board", () => { // TODO: SeoHead does not currently populate on this route. // Re-enable once the SeoHead component writes to document.title or uses <Helmet>. - test.fixme("page title is set on /ru/onlineboard", async ({ page }) => { + test.fixme("page title is set on /ru/onlineboard", async ({ page, consoleMessages }) => { await page.goto("/ru/onlineboard"); await page.waitForLoadState("domcontentloaded"); diff --git a/tests/e2e/onlineboard-day-tabs.spec.ts b/tests/e2e/onlineboard-day-tabs.spec.ts index 887c8013..8e0e025d 100644 --- a/tests/e2e/onlineboard-day-tabs.spec.ts +++ b/tests/e2e/onlineboard-day-tabs.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; // TIRREDESIGN-8: Onlineboard day-tabs must remain unblocked across the // full -1/+14 window, and must surface out-of-range dates greyed-out @@ -12,6 +12,7 @@ import { test, expect } from "@playwright/test"; test.describe("TIRREDESIGN-8 — Onlineboard day-tabs", () => { test("strip exposes the full -1/+14 range without blocking enabled tabs", async ({ page, + consoleMessages, }) => { await page.goto("/ru-ru/onlineboard/route/MOW-LED-20260423"); await expect(page.getByTestId("day-tabs")).toBeVisible({ timeout: 15000 }); @@ -58,7 +59,7 @@ test.describe("TIRREDESIGN-8 — Onlineboard day-tabs", () => { await expect(page.getByTestId("day-tabs-next")).toBeDisabled(); }); - test("clicking enabled tabs does not disable siblings", async ({ page }) => { + test("clicking enabled tabs does not disable siblings", async ({ page, consoleMessages }) => { await page.goto("/ru-ru/onlineboard/route/MOW-LED-20260423"); await expect(page.getByTestId("day-tabs")).toBeVisible({ timeout: 15000 }); diff --git a/tests/e2e/onlineboard-row-actions.spec.ts b/tests/e2e/onlineboard-row-actions.spec.ts index e7b2e60e..2ffc6739 100644 --- a/tests/e2e/onlineboard-row-actions.spec.ts +++ b/tests/e2e/onlineboard-row-actions.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; // TIRREDESIGN-10 — Onlineboard list rows must surface "Купить билет" // and "Онлайн регистрация" inside the expanded body when the per-flight @@ -15,6 +15,7 @@ import { test, expect } from "@playwright/test"; test("Onlineboard expanded row shows Купить билет + Онлайн регистрация when applicable", async ({ page, + consoleMessages, }) => { // Today in the harness clock. const today = new Date().toISOString().slice(0, 10).replace(/-/g, ""); diff --git a/tests/e2e/onlineboard-time-filter.spec.ts b/tests/e2e/onlineboard-time-filter.spec.ts index c142943c..e2a82a0c 100644 --- a/tests/e2e/onlineboard-time-filter.spec.ts +++ b/tests/e2e/onlineboard-time-filter.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; // TIRREDESIGN-11 — "Время рейса" slider must filter results. // @@ -13,6 +13,7 @@ const ROUTE_URL = "/ru-ru/onlineboard/route/MOW-LED-20260423"; test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => { test("URL with time-range suffix filters the list (URL → state path)", async ({ page, + consoleMessages, }) => { // Baseline: no filter await page.goto(ROUTE_URL); @@ -38,6 +39,7 @@ test.describe("Onlineboard time-range filter (TIRREDESIGN-11)", () => { test("dragging the slider + clicking Найти persists time range to URL", async ({ page, + consoleMessages, }) => { await page.goto(ROUTE_URL); await expect(page.locator(".flight-card").first()).toBeVisible({ diff --git a/tests/e2e/p1-urls-nav.spec.ts b/tests/e2e/p1-urls-nav.spec.ts index 783a2b23..0d749142 100644 --- a/tests/e2e/p1-urls-nav.spec.ts +++ b/tests/e2e/p1-urls-nav.spec.ts @@ -9,7 +9,7 @@ * §4.1.1 ¶12 — Flight-Map filter is independent (no cross-section carry-over) */ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; // --------------------------------------------------------------------------- // Helpers @@ -34,6 +34,7 @@ function daysFromNow(n: number): Date { test.describe("P1 — 4.1.2-R11: out-of-range dates redirect to start page", () => { test("Online-Board flight URL with date +30 days (beyond +14 window) redirects to /onlineboard", async ({ page, + consoleMessages, }) => { const far = fmt(daysFromNow(30)); await page.goto(`/ru/onlineboard/flight/SU1234-${far}`); @@ -48,6 +49,7 @@ test.describe("P1 — 4.1.2-R11: out-of-range dates redirect to start page", () test("Online-Board route URL with date -5 days (before -1 window) redirects to /onlineboard", async ({ page, + consoleMessages, }) => { const past = fmt(daysFromNow(-5)); await page.goto(`/ru/onlineboard/route/MOW-LED-${past}`); @@ -60,6 +62,7 @@ test.describe("P1 — 4.1.2-R11: out-of-range dates redirect to start page", () test("Schedule route URL with date +400 days (beyond +330 window) redirects to /schedule", async ({ page, + consoleMessages, }) => { const farFrom = fmt(daysFromNow(400)); const farTo = fmt(daysFromNow(407)); @@ -79,6 +82,7 @@ test.describe("P1 — 4.1.2-R11: out-of-range dates redirect to start page", () test.describe("P1 — 4.1.2-R10: unknown URL shows 404", () => { test("/ru/nonexistent does not crash — shows error or empty body", async ({ page, + consoleMessages, }) => { // Matches the existing pattern in navigation.spec.ts: the app handles // unknown routes gracefully (404 page or redirect, not a JS crash). @@ -89,6 +93,7 @@ test.describe("P1 — 4.1.2-R10: unknown URL shows 404", () => { test("/error/404 renders 404 content (client-side navigation)", async ({ page, + consoleMessages, }) => { // Direct URL navigate to the error route produces blank SSR output; // client-side assign is the established pattern (see navigation.spec.ts). @@ -113,6 +118,7 @@ test.describe("P1 — 4.1.2-R10: unknown URL shows 404", () => { test.describe("P1 — Table 7: breadcrumbs on start pages (Home only)", () => { test("Online-Board start page has exactly 1 breadcrumb (Home)", async ({ page, + consoleMessages, }) => { await page.goto("/ru/onlineboard"); await page.waitForLoadState("domcontentloaded"); @@ -126,6 +132,7 @@ test.describe("P1 — Table 7: breadcrumbs on start pages (Home only)", () => { test("Schedule start page has exactly 1 breadcrumb (Home)", async ({ page, + consoleMessages, }) => { await page.goto("/ru/schedule"); await page.waitForLoadState("domcontentloaded"); @@ -139,6 +146,7 @@ test.describe("P1 — Table 7: breadcrumbs on start pages (Home only)", () => { test("Flight-Map start page has exactly 1 breadcrumb (Home)", async ({ page, + consoleMessages, }) => { await page.goto("/ru/flights-map"); await page.waitForLoadState("domcontentloaded"); @@ -163,6 +171,7 @@ test.describe("P1 — Table 7: breadcrumbs on start pages (Home only)", () => { test.describe("P1 — Table 7: breadcrumbs on search pages", () => { test("Online-Board route search page has 2 breadcrumbs (Home + Section)", async ({ page, + consoleMessages, }) => { // Use an in-window date so the guard lets the page through. const today = fmt(new Date()); @@ -178,6 +187,7 @@ test.describe("P1 — Table 7: breadcrumbs on search pages", () => { test("Online-Board flight search page has 2 breadcrumbs (Home + Section)", async ({ page, + consoleMessages, }) => { const today = fmt(new Date()); await page.goto(`/ru/onlineboard/flight/SU0100-${today}`); @@ -190,6 +200,7 @@ test.describe("P1 — Table 7: breadcrumbs on search pages", () => { test("Schedule route search page has 3 breadcrumbs (Home + Section + Route heading)", async ({ page, + consoleMessages, }) => { const today = fmt(new Date()); const weekAhead = fmt(daysFromNow(7)); @@ -216,7 +227,7 @@ test.describe("P1 — Table 10: cross-section filter carry-over Board ↔ Schedu test.fixme( "navigating from Online-Board results to Schedule preserves departure/arrival cities", - async ({ page }) => { + async ({ page, consoleMessages }) => { // 1. Search on Online-Board (route tab) → MOW-LED. // 2. Navigate to /ru/schedule. // 3. Schedule start page filter should be pre-filled with MOW/LED via @@ -228,7 +239,7 @@ test.describe("P1 — Table 10: cross-section filter carry-over Board ↔ Schedu test.fixme( "navigating from Schedule results to Online-Board preserves departure/arrival cities", - async ({ page }) => { + async ({ page, consoleMessages }) => { // 1. Search on Schedule (route tab) → MOW-LED. // 2. Navigate to /ru/onlineboard. // 3. Online-Board start page filter should be pre-filled with MOW/LED. @@ -250,7 +261,7 @@ test.describe("P1 — §4.1.1 ¶12: Flight-Map filter is independent (no carry-o test.fixme( "Online-Board search does not affect Flight-Map departure city", - async ({ page }) => { + async ({ page, consoleMessages }) => { // 1. Search on Online-Board → MOW-LED. // 2. Navigate to /ru/flights-map. // 3. Flight-Map departure input should NOT show MOW (store is separate). diff --git a/tests/e2e/schedule-calendar-operating-days.spec.ts b/tests/e2e/schedule-calendar-operating-days.spec.ts index e606e67f..33912e71 100644 --- a/tests/e2e/schedule-calendar-operating-days.spec.ts +++ b/tests/e2e/schedule-calendar-operating-days.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; // TIRREDESIGN-12 — when both schedule cities are filled, the date-picker // must grey out the days the route does NOT operate. The fix in @@ -10,6 +10,7 @@ import { test, expect } from "@playwright/test"; test("Schedule calendar greys out non-operating days for the route", async ({ page, + consoleMessages, }) => { await page.goto("/ru-ru/schedule/route/MOW-MMK-20260427-20260503"); await expect(page.locator(".day-grouped-flight-list").first()).toBeVisible({ diff --git a/tests/e2e/schedule-date-picker.spec.ts b/tests/e2e/schedule-date-picker.spec.ts index 1123e3b7..e2134788 100644 --- a/tests/e2e/schedule-date-picker.spec.ts +++ b/tests/e2e/schedule-date-picker.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; // Schedule date picker — Angular parity (TZ §4.1.9.4): // • Single click on any day commits the **whole Mon-Sun week** that @@ -15,6 +15,7 @@ import { test, expect } from "@playwright/test"; test.describe("Schedule date-range picker (week-snap)", () => { test("single click snaps to Mon-Sun, closes panel, fills input", async ({ page, + consoleMessages, }) => { await page.goto("/ru-ru/schedule"); await expect(page.getByTestId("date-range-input")).toBeVisible({ @@ -39,6 +40,7 @@ test.describe("Schedule date-range picker (week-snap)", () => { test("clicking a next-month bleed-in day (3 May) snaps to 4-10 May", async ({ page, + consoleMessages, }) => { await page.goto("/ru-ru/schedule"); await expect(page.getByTestId("date-range-input")).toBeVisible({ @@ -60,7 +62,7 @@ test.describe("Schedule date-range picker (week-snap)", () => { ); }); - test("input renders as range placeholder when empty", async ({ page }) => { + test("input renders as range placeholder when empty", async ({ page, consoleMessages }) => { await page.goto("/ru-ru/schedule"); const input = page.locator("#schedule-date-from"); await expect(input).toHaveAttribute( diff --git a/tests/e2e/schedule-details-connecting-legs.spec.ts b/tests/e2e/schedule-details-connecting-legs.spec.ts index 78d86bce..4b469821 100644 --- a/tests/e2e/schedule-details-connecting-legs.spec.ts +++ b/tests/e2e/schedule-details-connecting-legs.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; // When the user clicks a connecting itinerary in the Schedule list, the // resulting flight-details URL must include EVERY leg, not just the @@ -10,6 +10,7 @@ import { test, expect } from "@playwright/test"; test("connecting itinerary navigates to a multi-segment URL with both legs rendered", async ({ page, + consoleMessages, }) => { await page.goto("/ru-ru/schedule/route/MOW-MMK-20260427-20260503"); await expect(page.locator(".flight-card").first()).toBeVisible({ diff --git a/tests/e2e/schedule-details-meal-sub-icons.spec.ts b/tests/e2e/schedule-details-meal-sub-icons.spec.ts index f8b88374..10e8ca95 100644 --- a/tests/e2e/schedule-details-meal-sub-icons.spec.ts +++ b/tests/e2e/schedule-details-meal-sub-icons.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; // Schedule Details "Питание на борту" must render meal-class sub-icons // (Эконом класс / Комфорт класс / Бизнес класс) ONLY when the API @@ -16,6 +16,7 @@ const URL = test("Питание sub-icons appear only for legs whose API meal[] contains them", async ({ page, + consoleMessages, }) => { await page.goto(URL); diff --git a/tests/e2e/schedule-details-mini-list-scoped.spec.ts b/tests/e2e/schedule-details-mini-list-scoped.spec.ts index 4ea1232f..ead34716 100644 --- a/tests/e2e/schedule-details-mini-list-scoped.spec.ts +++ b/tests/e2e/schedule-details-mini-list-scoped.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; // On the schedule details page the left mini-list renders a SINGLE // card for the currently-open flight — matching Angular's @@ -14,7 +14,7 @@ import { test, expect } from "@playwright/test"; const URL = "/ru-ru/schedule/VKO/SU6188-20260426/LED/SU6341-20260427/MMK?request=schedule-route-MOW-MMK-20260427-20260503"; -test("mini-list — one combined card for the open SU 6188+SU 6341 itinerary", async ({ page }) => { +test("mini-list — one combined card for the open SU 6188+SU 6341 itinerary", async ({ page, consoleMessages }) => { await page.goto(URL); const miniList = page.locator(".schedule-mini-list"); diff --git a/tests/e2e/schedule-details-summary-header.spec.ts b/tests/e2e/schedule-details-summary-header.spec.ts index 4db86634..685cef92 100644 --- a/tests/e2e/schedule-details-summary-header.spec.ts +++ b/tests/e2e/schedule-details-summary-header.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; // Schedule details page must render Angular's `<schedule-details-header>` // summary block between the day-tabs strip and the per-leg cards: @@ -15,7 +15,7 @@ import { test, expect } from "@playwright/test"; const URL = "/ru-ru/schedule/VKO/SU6188-20260426/LED/SU6341-20260427/MMK?request=schedule-route-MOW-MMK-20260427-20260503"; -test("summary header — both badges + last-update + formatted full-route timeline", async ({ page }) => { +test("summary header — both badges + last-update + formatted full-route timeline", async ({ page, consoleMessages }) => { await page.goto(URL); const summary = page.locator(".schedule-details__summary"); diff --git a/tests/e2e/schedule.spec.ts b/tests/e2e/schedule.spec.ts index 0db33690..d52f0e37 100644 --- a/tests/e2e/schedule.spec.ts +++ b/tests/e2e/schedule.spec.ts @@ -1,7 +1,7 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; test.describe("Schedule", () => { - test("/ru/schedule renders the start page", async ({ page }) => { + test("/ru/schedule renders the start page", async ({ page, consoleMessages }) => { await page.goto("/ru/schedule"); await page.waitForLoadState("domcontentloaded"); @@ -18,6 +18,7 @@ test.describe("Schedule", () => { test("/ru/schedule/route/SVO-LED-20260415 renders the search page", async ({ page, + consoleMessages, }) => { await page.goto("/ru/schedule/route/SVO-LED-20260415"); await page.waitForLoadState("domcontentloaded"); diff --git a/tests/e2e/search-history-label.spec.ts b/tests/e2e/search-history-label.spec.ts index cdd6c2e0..9aa29477 100644 --- a/tests/e2e/search-history-label.spec.ts +++ b/tests/e2e/search-history-label.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; // TIRREDESIGN-5: the search-history sidebar header must read // "Ранее искали" (not "Вы искали"). The block exists on Schedule @@ -30,7 +30,7 @@ async function seedHistory(page: import("@playwright/test").Page) { } test.describe("Search-history label is 'Ранее искали' (TIRREDESIGN-5)", () => { - test("Schedule start page", async ({ page }) => { + test("Schedule start page", async ({ page, consoleMessages }) => { await seedHistory(page); await page.goto("/ru-ru/schedule"); const block = page.getByTestId("search-history"); @@ -39,7 +39,7 @@ test.describe("Search-history label is 'Ранее искали' (TIRREDESIGN-5) await expect(block).not.toContainText("Вы искали"); }); - test("Online-Board start page", async ({ page }) => { + test("Online-Board start page", async ({ page, consoleMessages }) => { await seedHistory(page); await page.goto("/ru-ru/onlineboard"); const block = page.getByTestId("search-history"); diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 5d01601a..96ad98f6 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -1,7 +1,7 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures/console-gate"; test.describe("Smoke tests", () => { - test("root / redirects to /ru/onlineboard", async ({ page }) => { + test("root / redirects to /ru/onlineboard", async ({ page, consoleMessages }) => { await page.goto("/"); await page.waitForLoadState("domcontentloaded"); @@ -26,7 +26,7 @@ test.describe("Smoke tests", () => { } }); - test("/ru/smoke renders with Russian text", async ({ page }) => { + test("/ru/smoke renders with Russian text", async ({ page, consoleMessages }) => { await page.goto("/ru/smoke"); await page.waitForLoadState("domcontentloaded"); @@ -39,7 +39,7 @@ test.describe("Smoke tests", () => { await expect(page.locator("text=ru")).toBeVisible(); }); - test("/en/smoke renders with English text", async ({ page }) => { + test("/en/smoke renders with English text", async ({ page, consoleMessages }) => { await page.goto("/en/smoke"); await page.waitForLoadState("domcontentloaded"); @@ -50,7 +50,7 @@ test.describe("Smoke tests", () => { await expect(page.locator("text=en")).toBeVisible(); }); - test("/xx/smoke shows 404 or unknown locale message", async ({ page }) => { + test("/xx/smoke shows 404 or unknown locale message", async ({ page, consoleMessages }) => { await page.goto("/xx/smoke"); await page.waitForLoadState("domcontentloaded");