Merge feature/cicd-pipeline: Gitea Actions CI/CD pipeline
ci-deploy / build-deploy-test (push) Has been cancelled
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:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
Executable
+54
@@ -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"
|
||||
Executable
+61
@@ -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
|
||||
Executable
+78
@@ -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
|
||||
Executable
+23
@@ -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"
|
||||
Executable
+124
@@ -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
|
||||
Executable
+73
@@ -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
|
||||
Executable
+90
@@ -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"
|
||||
Executable
+36
@@ -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
|
||||
@@ -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 today−1 = 2026-05-14 so the route guard does
|
||||
// not redirect the search back to the start page.
|
||||
// Same-page Schedule click updates form state directly (navigate to
|
||||
// the same route would no-op), so we assert visible form state and
|
||||
// submit the form to verify the dates landed in component state.
|
||||
render(<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 today−1 (raw Mon was 2026-05-11)
|
||||
expect(stored.dateTo).toBe("20260517"); // Sun
|
||||
expect(stored.returnDateFrom).toBeUndefined();
|
||||
expect(stored.returnDateTo).toBeUndefined();
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule");
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
expect((screen.getByTestId("schedule-departure-input") as HTMLInputElement).value).toBe("SVO");
|
||||
expect((screen.getByTestId("schedule-arrival-input") as HTMLInputElement).value).toBe("LED");
|
||||
expect((screen.getByTestId("round-trip-toggle") as HTMLInputElement).checked).toBe(false);
|
||||
expect(screen.queryByTestId("return-date-range-input")).toBeNull();
|
||||
|
||||
// Submit drives the dates from state into the URL — proves they were set.
|
||||
fireEvent.submit(screen.getByTestId("schedule-search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517");
|
||||
});
|
||||
|
||||
it("4.1.5-S2: round-trip RouteWithBack click prefills current + next week dates (outbound from clamped)", () => {
|
||||
it("4.1.5-S2: round-trip RouteWithBack click populates form with current + next week dates (outbound from clamped)", () => {
|
||||
// current week raw: 20260511-20260517 (clamped from: 20260514-20260517)
|
||||
// next week: 20260518-20260524 (unclamped — future)
|
||||
render(<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
|
||||
// today−1 = 2026-05-14 so the range is inside Schedule's −1/+330 window.
|
||||
expect(stored.dateFrom).toBe("20260514");
|
||||
expect(stored.dateTo).toBe("20260517");
|
||||
fireEvent.submit(screen.getByTestId("schedule-search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -15,18 +15,28 @@ describe("4.1.9-R: formatScheduleDateRangeWithCurrentWeek", () => {
|
||||
).toBe("Текущая неделя");
|
||||
});
|
||||
|
||||
it("returns dd.MM.yyyy-dd.MM.yyyy for other ranges", () => {
|
||||
it("returns dd.MM.yyyy-dd.MM.yyyy for ranges that don't contain today", () => {
|
||||
const t = (k: string) => k;
|
||||
expect(
|
||||
formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 18), new Date(2026, 4, 24), t),
|
||||
).toBe("18.05.2026-24.05.2026");
|
||||
});
|
||||
|
||||
it("returns dd.MM.yyyy-dd.MM.yyyy for partial current week", () => {
|
||||
const t = (k: string) => k;
|
||||
it("returns 'Текущая неделя' for partial current week containing today (matches Angular)", () => {
|
||||
// today = 2026-05-15 (Fri); range 2026-05-13 .. 2026-05-17 contains today.
|
||||
// Angular's CalendarInputWeekComponent uses `from <= today <= to`.
|
||||
const t = (k: string) => (k === "SCHEDULE.CURRENT-WEEK" ? "Текущая неделя" : k);
|
||||
expect(
|
||||
formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 13), new Date(2026, 4, 17), t),
|
||||
).toBe("13.05.2026-17.05.2026");
|
||||
).toBe("Текущая неделя");
|
||||
});
|
||||
|
||||
it("returns 'Текущая неделя' for clamped popular-click range (today-1 .. Sun)", () => {
|
||||
// Popular-click on Schedule clamps `from` to today−1 = 2026-05-14.
|
||||
const t = (k: string) => (k === "SCHEDULE.CURRENT-WEEK" ? "Текущая неделя" : k);
|
||||
expect(
|
||||
formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 14), new Date(2026, 4, 17), t),
|
||||
).toBe("Текущая неделя");
|
||||
});
|
||||
|
||||
it("returns empty string for null inputs", () => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* Schedule range-calendar label substitution per TZ §4.1.9 Table 14.
|
||||
* Current week Mon-Sun → "Текущая неделя", otherwise dd.MM.yyyy-dd.MM.yyyy.
|
||||
* Any range containing today → "Текущая неделя", otherwise dd.MM.yyyy-dd.MM.yyyy.
|
||||
*
|
||||
* Matches Angular `CalendarInputWeekComponent.getDateString()`: substitutes
|
||||
* the label whenever `from <= today <= to`, not only on an exact Mon-Sun
|
||||
* match. This covers the popular-click case where the start page clamps
|
||||
* the outbound `from` to today−1 to stay inside Schedule's [-1, +330] window.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -12,14 +17,6 @@ function toYmd(d: Date): string {
|
||||
return `${day}.${month}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function mondayOfWeek(base: Date): Date {
|
||||
const d = new Date(base);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const offset = (d.getDay() + 6) % 7;
|
||||
d.setDate(d.getDate() - offset);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function formatScheduleDateRangeWithCurrentWeek(
|
||||
dateFrom: Date | null | undefined,
|
||||
dateTo: Date | null | undefined,
|
||||
@@ -28,14 +25,11 @@ export function formatScheduleDateRangeWithCurrentWeek(
|
||||
if (!dateFrom || !dateTo) return "";
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const thisMon = mondayOfWeek(today);
|
||||
const thisSun = new Date(thisMon);
|
||||
thisSun.setDate(thisSun.getDate() + 6);
|
||||
const from = new Date(dateFrom);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
const to = new Date(dateTo);
|
||||
to.setHours(0, 0, 0, 0);
|
||||
if (from.getTime() === thisMon.getTime() && to.getTime() === thisSun.getTime()) {
|
||||
if (from.getTime() <= today.getTime() && today.getTime() <= to.getTime()) {
|
||||
return t("SCHEDULE.CURRENT-WEEK");
|
||||
}
|
||||
return `${toYmd(from)}-${toYmd(to)}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"> — </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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}
|
||||
]
|
||||
}
|
||||
Executable
+53
@@ -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"
|
||||
Executable
+31
@@ -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"
|
||||
Executable
+61
@@ -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"
|
||||
Executable
+39
@@ -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"
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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, "");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user