Merge feature/cicd-pipeline: Gitea Actions CI/CD pipeline
ci-deploy / build-deploy-test (push) Has been cancelled

Two-workflow pipeline: ci-deploy (push → pve-201 swap+e2e) and release
(manual/tag → GitLab MR → Jenkins → customer e2e). Phase A — code only.
Phase B (host setup + first push) is a separate manual step.
This commit is contained in:
2026-04-25 18:49:51 +03:00
58 changed files with 2392 additions and 353 deletions
+130
View File
@@ -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
+200
View File
@@ -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"
+6 -1
View File
@@ -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
+199
View File
@@ -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:
<nic-name>: # 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 <runner-user> # 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=<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<TOKEN>/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 <prev-sha>`
- `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:<known-good-sha>'`.
### 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 <sha>`
- 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
```
@@ -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;
}
}
+1
View File
@@ -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",
+22 -7
View File
@@ -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,
},
}
: {}),
});
+54
View File
@@ -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=<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"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# check-gitlab-project.sh — verify GitLab project setup for the release pipeline.
#
# Usage: GITLAB_PAT=<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
+78
View File
@@ -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|rollback>
#
# `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] <swap|rollback>" >&2
exit 2
;;
esac
+23
View File
@@ -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: <user>:<hash>
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"
+124
View File
@@ -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 <fixture.json> # 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 <fixture.json>" >&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
+73
View File
@@ -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] <start|ok|fail> <stage> [<extra-context>]
#
# 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] <start|ok|fail> <stage> [<extra-context>]" >&2; exit 2 ;;
esac
[ -n "$STAGE" ] || { echo "usage: $0 [--dry-run] <start|ok|fail> <stage> [<extra-context>]" >&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
+90
View File
@@ -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 <target-dir>
#
# 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 <target-dir>" >&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"
+36
View File
@@ -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 <url> [<max-attempts>] [<delay-seconds>]
# 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 <url> [<max-attempts>] [<delay-seconds>]" >&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
+10 -132
View File
@@ -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"
@@ -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;
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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<ScheduleColumnHeadersProps> = ({
sortMode,
onSortChange,
}) => {
const { t } = useTranslation();
const toggle = (mode: ScheduleSortMode): void => {
onSortChange(sortMode === mode ? "none" : mode);
};
const sortBtn = (mode: ScheduleSortMode, dir: "up" | "down") => (
<button
type="button"
onClick={() => toggle(mode)}
className={`schedule-col-header__sort schedule-col-header__sort--${dir}${
sortMode === mode ? " schedule-col-header__sort--active" : ""
}`}
aria-label={`${dir === "up" ? "↑" : "↓"} ${mode}`}
data-testid={`schedule-sort-${mode}`}
>
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
{dir === "up" ? (
<path d="M5 2L9 8H1Z" fill="currentColor" />
) : (
<path d="M5 8L1 2H9Z" fill="currentColor" />
)}
</svg>
</button>
);
return (
<div
className="schedule-col-header"
data-testid="schedule-column-headers"
>
<div className="schedule-col-header__flight">
<div className="schedule-col-header__label">
{t("SCHEDULE.COL-FLIGHT") || "РЕЙС"}
</div>
</div>
<div className="schedule-col-header__company">
<div className="schedule-col-header__label">
{t("SCHEDULE.COL-AIRLINE") || "АВИАКОМПАНИЯ, БОРТ"}
</div>
</div>
<div className="schedule-col-header__departure">
<div className="schedule-col-header__label schedule-col-header__label--note">
{t("SCHEDULE.COL-DEPARTURE") || "ВЫЛЕТ"}
<span className="schedule-col-header__note" aria-hidden="true">*</span>
</div>
<div className="schedule-col-header__sort-container">
{sortBtn("departureUp", "up")}
{sortBtn("departureDown", "down")}
</div>
</div>
<div className="schedule-col-header__time">
<div className="schedule-col-header__label">
{t("SCHEDULE.COL-DURATION") || "ВРЕМЯ В ПУТИ"}
</div>
<div className="schedule-col-header__sort-container">
{sortBtn("timeUp", "up")}
{sortBtn("timeDown", "down")}
</div>
</div>
<div className="schedule-col-header__arrival">
<div className="schedule-col-header__label schedule-col-header__label--note">
{t("SCHEDULE.COL-ARRIVAL") || "ПРИЛЕТ"}
<span className="schedule-col-header__note" aria-hidden="true">*</span>
</div>
<div className="schedule-col-header__sort-container">
{sortBtn("arrivalUp", "up")}
{sortBtn("arrivalDown", "down")}
</div>
</div>
</div>
);
};
@@ -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 {
@@ -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<string, unknown>) => (
<input
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
defaultValue={(props["value"] as string) ?? ""}
value={(props["value"] as string) ?? ""}
readOnly
/>
),
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 today1 = 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(<ScheduleStartPage />);
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 today1 (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(<ScheduleStartPage />);
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(<ScheduleStartPage />);
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(<ScheduleStartPage />);
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
// today1 = 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");
});
});
+64 -21
View File
@@ -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;
}
@@ -133,7 +133,9 @@ export const WeekTabs: FC<WeekTabsProps> = ({ selectedMonday, onNavigate }) => {
onClick={() => setPage((p) => Math.max(0, p - 1))}
aria-label={t("SHARED.A11Y-PREV-PAGE")}
>
<svg viewBox="0 0 8 12" width="8" height="12" aria-hidden="true">
<path d="M7 1L2 6L7 11" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className="week-tabs__list">
{activeSlice.map((w) => {
@@ -174,7 +176,9 @@ export const WeekTabs: FC<WeekTabsProps> = ({ selectedMonday, onNavigate }) => {
onClick={() => setPage((p) => Math.min(activeTotalPages - 1, p + 1))}
aria-label={t("SHARED.A11Y-NEXT-PAGE")}
>
<svg viewBox="0 0 8 12" width="8" height="12" aria-hidden="true">
<path d="M1 1L6 6L1 11" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</nav>
);
+14 -4
View File
@@ -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 today1 = 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", () => {
+7 -13
View File
@@ -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 today1 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)}`;
@@ -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;
}
+16
View File
@@ -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<Record<string, string>> = {
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];
}
+15 -2
View File
@@ -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;
+91
View File
@@ -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;
+63 -1
View File
@@ -355,7 +355,19 @@ export const FlightCard: FC<FlightCardProps> = ({
: {})}
>
<div className="flight-card__number" data-testid="flight-carrier-number">
<div>{flightNumber}</div>
{/* 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) => (
<div key={`${id.carrier}-${id.flightNumber}-${i}`}>
{id.carrier} {id.flightNumber}{id.suffix ?? ""}{i < childFlightIds.length - 1 ? "," : ""}
</div>
))
) : (
<div>{flightNumber}</div>
)}
{expanded && flight.routeType === "Direct" && aircraftName && (
<div className="flight-card__aircraft">{aircraftName}</div>
)}
@@ -480,6 +492,56 @@ export const FlightCard: FC<FlightCardProps> = ({
)}
</div>
{/* 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 && (
<div
className="flight-card__transfer"
data-testid="flight-card-transfer"
>
<span className="flight-card__transfer-icon" aria-hidden="true">
<svg viewBox="0 0 20 8" width="20" height="8">
<circle cx="3" cy="4" r="3" fill="currentColor" />
<path d="M6 4h8" stroke="currentColor" strokeWidth="1.5" />
<circle cx="17" cy="4" r="3" fill="currentColor" />
</svg>
</span>
<span className="flight-card__transfer-label">
{t(
flight.legs.length > 2
? "SHARED.INTERMEDIATE-LANDING-PLURAL-OTHER"
: "SHARED.FLIGHT-TRANSFER-PLURAL-ONE",
)}
</span>
<span className="flight-card__transfer-dash">&nbsp;&mdash;&nbsp;</span>
<span className="flight-card__transfer-stations">
{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 (
<span key={`tr-${i}`}>
{i > 0 ? ", " : ""}
{s.city}
{s.airport ? (
<>
{", "}
<span className="flight-card__transfer-airport">
{airportWithTerminal}
</span>
</>
) : null}
</span>
);
})}
</span>
</div>
)}
{expandable && expanded && renderExpandedBody && (
<div
className="flight-card__expanded flight-card__expanded--custom"
+13
View File
@@ -26,6 +26,19 @@
color: colors.$light-gray;
text-decoration: underline;
line-height: 16px;
// The `--link` variant renders an <a> 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 {
+44 -6
View File
@@ -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<StationDisplayProps> = ({
}) => {
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 ? (
<a
className="station__terminal station__terminal--link"
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={stopBubble}
title={airportTooltip}
>
{terminalLine}
</a>
) : (
<span className="station__terminal" title={airportTooltip}>
{terminalLine}
</span>
)
) : null;
if (cityFirst) {
return (
<div className="station station--city-first">
<span className="station__city station__city--bold">{resolvedCity}</span>
{terminalLine ? (
<span className="station__terminal">{terminalLine}</span>
) : null}
<span
className="station__city station__city--bold"
title={cityTooltip}
>
{resolvedCity}
</span>
{terminalEl}
</div>
);
}
@@ -50,7 +86,9 @@ export const StationDisplay: FC<StationDisplayProps> = ({
{airportName ? (
<span className="station__name">{airportName}</span>
) : null}
<span className="station__city">{resolvedCity}</span>
<span className="station__city" title={cityTooltip}>
{resolvedCity}
</span>
</div>
);
};
@@ -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}}
]
}
@@ -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}}
]
}
+53
View File
@@ -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"
+31
View File
@@ -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"
+61
View File
@@ -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"
+39
View File
@@ -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"
+3 -2
View File
@@ -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
@@ -0,0 +1,3 @@
{
"patterns": []
}
+72
View File
@@ -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<ConsoleGateFixtures>({
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 };
+2 -1
View File
@@ -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");
+5 -3
View File
@@ -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");
+17 -8
View File
@@ -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 <title> 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");
+3 -2
View File
@@ -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 });
+2 -1
View File
@@ -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, "");
+3 -1
View File
@@ -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({
+15 -4
View File
@@ -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).
@@ -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({
+4 -2
View File
@@ -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(
@@ -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({
@@ -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);
@@ -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");
@@ -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");
+3 -2
View File
@@ -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");
+3 -3
View File
@@ -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");
+5 -5
View File
@@ -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");