Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a153dc8765 | ||
|
|
3620bb734c | ||
|
|
830e1d0f0d | ||
|
|
a1bb3eab98 | ||
|
|
8bd2b53469 | ||
|
|
21325d4e5c | ||
|
|
48cb424afd | ||
|
|
e5a8326a5e | ||
|
|
01380d3c09 | ||
|
|
6a3a6f8da5 | ||
|
|
224646e9d7 | ||
|
|
1d5eda3d89 | ||
|
|
8f58995fd0 | ||
|
|
25c62d3fff | ||
|
|
2e77f6c3df | ||
|
|
28e42e8b55 | ||
|
|
2d3f0a35c6 | ||
|
|
aae9657b9e | ||
|
|
a2ef8e0bf0 | ||
|
|
5a80c021d3 | ||
|
|
133d847608 | ||
|
|
043d9d632d | ||
|
|
d01fdabd68 | ||
|
|
84ae32b8a3 | ||
|
|
1f6129e009 | ||
|
|
d6799ec59b | ||
|
|
fcab9e1239 | ||
|
|
d2fb4b81c0 | ||
|
|
f922f6e8c7 | ||
|
|
06e350fb1e | ||
|
|
e522b2eee3 | ||
|
|
228dfdaca0 | ||
|
|
6357648c9c | ||
|
|
b845316ee7 | ||
|
|
2d3b34f34b | ||
|
|
cc8cc1bce6 | ||
|
|
a68d3c315e | ||
|
|
ba669fad34 | ||
|
|
8f64131131 | ||
|
|
802bfd117e | ||
|
|
2281705c95 | ||
|
|
fc0473e7e2 | ||
|
|
e12a8a919c | ||
|
|
37050b1458 |
No files matched your search
@@ -51,7 +51,8 @@ jobs:
|
||||
infra/runner/test-install-runner-idempotency.sh infra/runner/test-install-runner-guard.sh \
|
||||
infra/runner/test-ci-container-builds.sh
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/runner/secure-runner-image.py').read_text())"
|
||||
- name: Run rollback helper tests
|
||||
python3 -c "import ast,pathlib; ast.parse(pathlib.Path('infra/deploy/validate_release.py').read_text())"
|
||||
- name: Run deployment contract and rollback tests
|
||||
run: python3 -m unittest discover -s infra/deploy -p 'test_*.py' -v
|
||||
- name: Validate production Compose and Caddyfile
|
||||
run: |
|
||||
|
||||
@@ -5,6 +5,7 @@ credentials.json
|
||||
__pycache__/
|
||||
.venv/
|
||||
*.py[cod]
|
||||
tools/frontend-visual-review/node_modules/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
|
||||
@@ -2,6 +2,56 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Frontend `0.3.3` / Backend `0.13.2`: управление пользователями отображается
|
||||
согласно правам аккаунта, подписи ролей локализованы, повторяющиеся действия
|
||||
кабинета убраны, а знак лаборатории приведён к основному бренду.
|
||||
- Frontend `0.3.2`: устранены пересечения названия бренда с поиском и
|
||||
мобильной навигации документа; навигационные ссылки получили общие quiet и
|
||||
primary состояния.
|
||||
- Frontend `0.3.1`: уточнена компоновка поиска, лаборатории и кабинета;
|
||||
splitter сохраняет минимальную ширину панелей, а на средних экранах фильтры
|
||||
открываются доступной боковой панелью. Исправлено отображение футера на
|
||||
маршруте пользователей и browser fixture смены пароля.
|
||||
- Frontend `0.3.0` / Backend `0.13.1`: добавлен доступный splitter для
|
||||
изменения ширины результатов и документа. Язык поиска находится в фильтрах;
|
||||
состояния открытия, оглавление правовых заголовков, лаборатория и кабинет
|
||||
приведены к последовательной и компактной компоновке.
|
||||
- Frontend `0.2.6` / Backend `0.13.1`: двухпанельная компоновка поиска
|
||||
сохраняется до ширины 1175 px, чтобы три панели не обрезались при
|
||||
промежуточных ширинах окна.
|
||||
- Frontend `0.2.5` / Backend `0.13.1`: устранены промежуточные переполнения
|
||||
навигации, старый текст больше не остаётся при ошибке загрузки другого
|
||||
документа; очищаются дополнительные URL-атрибуты юридической разметки,
|
||||
включая `srcset` и `ping`. Добавлены fixture-проверки ошибок и гонок загрузки,
|
||||
изоляции ссылок, reflow и безопасных форм управления пользователями.
|
||||
- Frontend `0.2.4` / Backend `0.13.1`: исправлены состояния и адаптивный поиск,
|
||||
чтение документа и лаборатории, доступные диалоги; управление пользователями
|
||||
вынесено на отдельный защищённый экран. Документная разметка очищается от
|
||||
активного HTML и небезопасных ссылок. Добавлены проверки маршрутов и reflow;
|
||||
правовой текст и API управления пользователями не менялись.
|
||||
- Frontend `0.2.3`: добавлен публичный поиск по правовой базе с фильтрами,
|
||||
адаптивной выдачей, просмотром редакций и поиском по тексту документа;
|
||||
внутренняя лаборатория сохранена, её подсказки стали компактнее и привязаны
|
||||
к целевым элементам. Интерфейс использует локальные шрифты Noto Sans и Noto
|
||||
Serif.
|
||||
- Исправлены переключение на доступный язык редакции, очистка содержимого и
|
||||
ссылки на официальный источник при смене документа или ошибке загрузки;
|
||||
подписи фильтров локализуются на кыргызский язык.
|
||||
- Infrastructure: systemd unit Gitea Actions runner теперь использует
|
||||
`ProtectHome=read-only`, сохраняя доступность `/run/user` и существующий
|
||||
writable carve-out для rootless runtime; VPS не изменялся.
|
||||
- Backend `0.12.1`: добавлен ограниченный возобновляемый staging редакций из
|
||||
текущего API Минюста с проверкой точных ID и метаданных, изоляцией от
|
||||
legacy-корпуса и сохранением исходного порядка редакций при нормализации и
|
||||
экспорте. Тест overlap guard задаёт безопасные права fixture независимо от
|
||||
`umask` CI; сам guard не ослаблен.
|
||||
- Rollback preflight проверяет digest-pinned image references по сохранённому
|
||||
image ID и останавливается до изменений deployment; переключение production
|
||||
на digest и полноценный digest rollback пока не реализованы.
|
||||
- Infrastructure: добавлен офлайн-контракт release manifest с точной схемой,
|
||||
digest-ссылками и repository allowlist; проверен Compose override с
|
||||
`--no-build` как требованием будущего deploy. Публикация, provenance и
|
||||
production-деплой не включены.
|
||||
- Добавлен read-only возобновляемый inventory корпуса с полным хешированием,
|
||||
потоковой сверкой, индексируемыми диапазонами путей и provenance SHA-256
|
||||
проверяющего; значения `stat` хранятся без ограничения SQLite INTEGER.
|
||||
|
||||
@@ -86,6 +86,15 @@ spacing:
|
||||
генеративного изображения. Название продукта — **Акылдаш**; «УКУК» из
|
||||
ранних концепций не используется.
|
||||
|
||||
Изображение определяет целевую композицию, пропорции панелей и расположение
|
||||
элементов поискового экрана. Эта спецификация определяет семантические токены,
|
||||
шрифты, доступность и правила поведения. Расхождения между ними фиксируются
|
||||
до реализации; утверждённые токены не заменяются цветами, взятыми пипеткой
|
||||
из растрового изображения. Изменения постоянных правил вносятся сюда явно.
|
||||
Этапы работ, исходные измерения и критерии визуальной приёмки находятся в
|
||||
[едином плане корректировки UI/UX](docs/product/interface-correction-plan.md).
|
||||
План не является второй дизайн-системой и не означает завершение реализации.
|
||||
|
||||
**Key Characteristics:**
|
||||
|
||||
- Спокойное чтение и компактные, понятные элементы управления.
|
||||
@@ -162,6 +171,34 @@ spacing:
|
||||
Разворачивание документа и поиск внутри него реализуются только вместе
|
||||
с работающим поведением, а не как декоративные кнопки.
|
||||
|
||||
### Композиция поискового экрана
|
||||
|
||||
- Первая строка desktop: знак и название, разделитель и подпись продукта
|
||||
слева; подписанное поле поиска с очисткой и кнопкой «Найти» в центре;
|
||||
переключатель языка интерфейса справа. Служебные действия авторизованного
|
||||
пользователя не вытесняют поиск и не меняют эту иерархию.
|
||||
- Вторая строка: навигация «Поиск» / «Об источниках» и удаляемые активные
|
||||
фильтры. Сведения о демонстрационном режиме показываются только в нём.
|
||||
- Ниже расположены три смежные панели: фильтры, результаты, документ.
|
||||
Иерархия создаётся фонами и непрерывными тонкими разделителями, без
|
||||
промежутков между самостоятельными карточками панелей.
|
||||
- Фильтры имеют компактные группы, текстовое действие сброса, выбор органа
|
||||
и пару полей даты в одной строке при достаточной ширине. Вид элемента
|
||||
выбора соответствует одиночному или множественному контракту API.
|
||||
- Заголовок результатов и счётчик находятся слева, сортировка справа.
|
||||
Высота строк зависит от содержимого; едины отступы и типографика.
|
||||
Выбранный результат имеет мягкий красный фон, тонкую красную рамку
|
||||
и текстовый признак открытия.
|
||||
- Панель документа начинается с контекстной навигации и разворачивания,
|
||||
затем идут название, компактные метаданные, действия, поиск внутри
|
||||
документа и единое поле чтения. Название документа имеет отдельную
|
||||
типографическую роль, отличную от заголовков интерфейсных панелей.
|
||||
- При коротком содержимом footer расположен у нижней границы окна;
|
||||
длинное содержимое доступно прокруткой без перекрытия текста и управления.
|
||||
Бренд находится слева, фактические версии frontend и backend справа.
|
||||
- Поисковая страница не показывает инструменты оценивания лаборатории.
|
||||
Общие стили и ресурсы не смешивают композицию этих двух сценариев.
|
||||
|
||||
Внутренняя лаборатория сохраняет свой двухпанельный сценарий: фактический
|
||||
порядок результатов и оценивание слева, текст выбранной редакции справа.
|
||||
Она не получает фильтры, аналитику или публичную навигацию исключительно
|
||||
@@ -176,8 +213,9 @@ spacing:
|
||||
горизонтальной прокрутки; широкие таблицы имеют отдельную обозначенную
|
||||
область прокрутки.
|
||||
|
||||
Проверочные размеры при реализации: **1440, 1024 и 390 px**. Это размеры
|
||||
проверки, а не заранее навязанные CSS breakpoints.
|
||||
Проверочные размеры при реализации: **1280×800, 1440, 1024 и 390 px**.
|
||||
1280×800 — основной размер сравнения поискового экрана с референсом.
|
||||
Это размеры проверки, а не заранее навязанные CSS breakpoints.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
@@ -204,6 +242,13 @@ spacing:
|
||||
помечается `aria-hidden`, не находится под текстом и не мешает нажатию.
|
||||
На телефоне орнамент сокращается либо убирается.
|
||||
|
||||
Поисковый desktop включает небольшой золотистый орнамент в свободной нижней
|
||||
части панели фильтров. Он не перекрывает длинные списки и не резервирует
|
||||
место ценой недоступности управления. Знак бренда и орнамент поставляются
|
||||
как SVG-ресурсы. Favicon использует тот же знак, адаптированный для малых
|
||||
размеров; проверяется читаемость 16×16 и 32×32. Иконки управления имеют
|
||||
согласованные толщину линий, оптический размер и выравнивание.
|
||||
|
||||
## Components
|
||||
|
||||
Это требования к будущей реализации, а не перечень уже обновлённых компонентов.
|
||||
|
||||
@@ -10,7 +10,9 @@ Telegram-бот — только часть рабочего окружения
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
|
||||
`0.11.3` и внутренний frontend лаборатории `0.1.2`. Авторизация хранится в
|
||||
`0.13.2` и frontend `0.3.3` с поисковым экраном, внутренней лабораторией и
|
||||
согласованным отображением доступных действий учетной записи.
|
||||
Авторизация хранится в
|
||||
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
|
||||
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
|
||||
действующий Налоговый кодекс для запросов о налоге на прибыль.
|
||||
@@ -18,8 +20,8 @@ SQLite; первый пользователь `admin` создаётся инт
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.11.3` | авторизация и поиск с фильтрами; тематический поиск ОсОО/ЖЧК |
|
||||
| Frontend | `0.1.2` | лаборатория поиска, личный кабинет и управление учетными записями |
|
||||
| Backend | `0.13.2` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
|
||||
| Frontend | `0.3.3` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
|
||||
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||
| RAG и база знаний | — | ещё не созданы |
|
||||
|
||||
@@ -62,4 +64,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.2 · Frontend v0.3.3
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.11.3`
|
||||
Версия: `0.13.0`
|
||||
|
||||
Первая backend-область проекта — загрузка правовых документов из официального
|
||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||
@@ -54,6 +54,49 @@ result = sync_archive(output_path)
|
||||
Планировщик, очередь задач и PostgreSQL пока не добавлены: модуль не зависит от
|
||||
выбора будущего backend-фреймворка.
|
||||
|
||||
## Текущий API Минюста и отдельный staging
|
||||
|
||||
Старый OpenData endpoint может возвращать `404`; для отдельной ограниченной
|
||||
выгрузки официальный сайт предоставляет `/api/v1/GetDocument` и
|
||||
`/api/v1/GetEdition`. Адаптер `ingestion.minjust_current` получает один
|
||||
указанный документ и сохраняет его в отдельное поколение staging:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend python3 -m ingestion.minjust_current \
|
||||
--output /tmp/minjust-current-code6 \
|
||||
--document-code 6
|
||||
```
|
||||
|
||||
Продолжайте прерванную выгрузку только с тем же каталогом и `--resume`. По
|
||||
умолчанию лимит скачанного текста одного документа — 4 GiB, лимит запросов —
|
||||
один в секунду; каталог не должен совпадать или пересекаться с
|
||||
`data/minjust-cbd` и
|
||||
`data/minjust-normalized`. Адаптер сохраняет исходные ответы и checkpoints,
|
||||
публикует папку документа атомарно и не заменяет существующий код в staging.
|
||||
Каталог staging должен быть отдельной директорией режима 0700, принадлежащей
|
||||
текущему пользователю; его родитель должен быть существующим доверенным
|
||||
каталогом. Символические ссылки в generation, checkpoints и каталогах
|
||||
документов отклоняются. Legacy-корни вычисляются от корня репозитория, а не
|
||||
от текущего рабочего каталога.
|
||||
Для `/GetEdition?lang=kg` официальный ответ помещает кыргызский HTML в
|
||||
`contentRu` при `contentKg=null`; архивный язык определяется запрошенным
|
||||
`lang`, а не именем поля ответа.
|
||||
|
||||
Исторические редакции запрашиваются только с `exact=true`; без этого параметра
|
||||
официальный endpoint возвращал ID последней редакции даже для другого
|
||||
запрошенного ID. Адаптер требует совпадения response ID и запрошенного ID,
|
||||
сверяет названия RU/KY с metadata `GetDocument` и сохраняет request contract в
|
||||
checkpoint. Старые staging generations без этого contract нельзя возобновить.
|
||||
`exact=true` и совпадение ID проверены на двух контрольных редакциях; это не
|
||||
подтверждает полноту всего источника.
|
||||
|
||||
Порядок редакций сохраняется по ответу `/GetDocument` и используется как
|
||||
официальный порядок при нормализации и выборе текущей редакции в поисковом
|
||||
индексе. Вложения и встроенные изображения этим адаптером не подтверждаются:
|
||||
staging остаётся неполным до отдельной проверки медиа и не заменяет корпус.
|
||||
Получение полного списка документов не является частью этой ограниченной
|
||||
процедуры.
|
||||
|
||||
## Нормализация архива
|
||||
|
||||
Нормализатор читает исходный архив без изменений и создаёт отдельный набор
|
||||
@@ -253,4 +296,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Backend v0.11.3 · Frontend v0.1.2
|
||||
Акылдаш · Backend v0.13.0 · Frontend v0.2.3
|
||||
@@ -0,0 +1,720 @@
|
||||
"""Read-only staging client for the current MinJust CBD API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from ingestion.minjust_cbd import atomic_write, connect_manifest, json_bytes
|
||||
|
||||
API_ROOT = "https://cbd.minjust.gov.kg/api/v1"
|
||||
APP_VERSION = "0.13.0"
|
||||
REQUEST_CONTRACT = {
|
||||
"version": 2,
|
||||
"endpoint": "/GetEdition",
|
||||
"edition_id_parameter": "editionId",
|
||||
"exact_parameter": "true",
|
||||
"language_parameter": "lang",
|
||||
"response_id_must_match": True,
|
||||
}
|
||||
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
||||
DEFAULT_DOCUMENT_BUDGET = 4 * 1024 * 1024 * 1024
|
||||
LANGUAGES = (("ru", "ru", "textRusType"), ("kg", "ky", "textKyrType"))
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class SourceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SourceChanged(SourceError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiResponse:
|
||||
value: dict
|
||||
body: bytes
|
||||
sha256: str
|
||||
|
||||
|
||||
class CurrentCbdClient:
|
||||
def __init__(
|
||||
self,
|
||||
requests_per_second: float = 1,
|
||||
timeout: int = 60,
|
||||
retries: int = 5,
|
||||
max_response_bytes: int = MAX_RESPONSE_BYTES,
|
||||
opener: Callable = urllib.request.urlopen,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
if requests_per_second <= 0 or timeout <= 0 or retries <= 0 or max_response_bytes <= 0:
|
||||
raise ValueError("rate, timeout, retries, and response limit must be positive")
|
||||
self.interval = 1 / requests_per_second
|
||||
self.timeout = timeout
|
||||
self.retries = retries
|
||||
self.max_response_bytes = max_response_bytes
|
||||
self.opener = opener
|
||||
self.sleep = sleep
|
||||
self.clock = clock
|
||||
self.last_request = 0.0
|
||||
|
||||
def request_json(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
parameters: tuple[tuple[str, object], ...] = (),
|
||||
payload: dict | None = None,
|
||||
) -> ApiResponse:
|
||||
if not path.startswith("/") or ".." in path.split("/"):
|
||||
raise ValueError("API path must be an absolute endpoint path")
|
||||
query = urllib.parse.urlencode(parameters, doseq=True)
|
||||
url = API_ROOT + path + (f"?{query}" if query else "")
|
||||
body = json.dumps(payload or {}, ensure_ascii=False, separators=(",", ":")).encode() if method == "POST" else None
|
||||
for attempt in range(self.retries):
|
||||
wait = self.interval - (self.clock() - self.last_request)
|
||||
if wait > 0:
|
||||
self.sleep(wait)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method=method,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
**({"Content-Type": "application/json"} if body is not None else {}),
|
||||
"User-Agent": f"Akyldash/{APP_VERSION} (+https://cbd.minjust.gov.kg)",
|
||||
},
|
||||
)
|
||||
try:
|
||||
self.last_request = self.clock()
|
||||
with self.opener(request, timeout=self.timeout) as response:
|
||||
raw = response.read(self.max_response_bytes + 1)
|
||||
if len(raw) > self.max_response_bytes:
|
||||
raise SourceError(f"MinJust response exceeds {self.max_response_bytes} bytes")
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise SourceError("MinJust response must be a JSON object")
|
||||
return ApiResponse(value, raw, hashlib.sha256(raw).hexdigest())
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code != 429 and error.code < 500:
|
||||
raise SourceError(f"MinJust returned HTTP {error.code} for {path}") from error
|
||||
delay = 2**attempt
|
||||
if error.code == 429:
|
||||
delay = max(delay, retry_after_seconds(error.headers.get("Retry-After")))
|
||||
except (TimeoutError, urllib.error.URLError, http.client.IncompleteRead, json.JSONDecodeError) as error:
|
||||
delay = 2**attempt
|
||||
LOGGER.warning("MinJust request failed for %s (attempt %s/%s)", path, attempt + 1, self.retries)
|
||||
last_error = error
|
||||
if attempt + 1 < self.retries:
|
||||
self.sleep(delay)
|
||||
raise SourceError(f"MinJust request failed after {self.retries} attempts: {path}") from locals().get("last_error")
|
||||
|
||||
def document(self, alias: str) -> ApiResponse:
|
||||
response = self.request_json("/GetDocument", parameters=(("DocumentCode", alias),))
|
||||
value = response.value
|
||||
canonical = value.get("documentCode")
|
||||
editions = value.get("editions")
|
||||
if not isinstance(canonical, int) or isinstance(canonical, bool) or canonical < 1:
|
||||
raise SourceError("MinJust document has an invalid canonical documentCode")
|
||||
if not isinstance(editions, list):
|
||||
raise SourceError("MinJust document is missing editions")
|
||||
ids = [edition_id(item) for item in editions]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise SourceError(f"MinJust document {canonical} contains duplicate edition IDs")
|
||||
return response
|
||||
|
||||
def edition(self, identifier: int, language: str) -> ApiResponse:
|
||||
if identifier < 1 or language not in {"ru", "kg"}:
|
||||
raise ValueError("edition ID or requested language is invalid")
|
||||
response = self.request_json(
|
||||
"/GetEdition",
|
||||
parameters=(("editionId", identifier), ("lang", language), ("exact", "true")),
|
||||
)
|
||||
returned_id = response.value.get("id")
|
||||
if not isinstance(returned_id, int) or isinstance(returned_id, bool) or returned_id < 1:
|
||||
raise SourceError(f"MinJust returned an invalid edition response ID for {identifier}")
|
||||
if returned_id != identifier:
|
||||
raise SourceChanged(f"MinJust exact edition ID {returned_id} does not match requested ID {identifier}")
|
||||
return response
|
||||
|
||||
|
||||
def retry_after_seconds(value: str | None) -> float:
|
||||
if not value:
|
||||
return 0
|
||||
try:
|
||||
return max(0, float(value))
|
||||
except ValueError:
|
||||
try:
|
||||
return max(0, (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds())
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 0
|
||||
|
||||
|
||||
def edition_id(value: object) -> int:
|
||||
if not isinstance(value, dict):
|
||||
raise SourceError("MinJust document contains an invalid edition")
|
||||
identifier = value.get("id")
|
||||
if not isinstance(identifier, int) or isinstance(identifier, bool) or identifier < 1:
|
||||
raise SourceError("MinJust edition has an invalid id")
|
||||
return identifier
|
||||
|
||||
|
||||
def open_manifest(path: Path) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(path)
|
||||
connection.execute("PRAGMA journal_mode=DELETE")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
alias TEXT PRIMARY KEY,
|
||||
canonical_id INTEGER,
|
||||
response_sha256 TEXT,
|
||||
expected_editions INTEGER,
|
||||
state TEXT NOT NULL,
|
||||
error TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS editions (
|
||||
canonical_id INTEGER NOT NULL,
|
||||
edition_id INTEGER NOT NULL,
|
||||
source_order INTEGER NOT NULL,
|
||||
response_sha256 TEXT NOT NULL,
|
||||
PRIMARY KEY (canonical_id, edition_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS languages (
|
||||
canonical_id INTEGER NOT NULL,
|
||||
edition_id INTEGER NOT NULL,
|
||||
request_language TEXT NOT NULL,
|
||||
archive_language TEXT NOT NULL,
|
||||
response_sha256 TEXT,
|
||||
content_sha256 TEXT,
|
||||
byte_count INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL,
|
||||
error TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (canonical_id, edition_id, request_language)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS generation (
|
||||
contract_sha256 TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
|
||||
|
||||
def reject_symlink_components(path: Path) -> None:
|
||||
current = Path(path.anchor)
|
||||
for part in path.parts[1:]:
|
||||
current /= part
|
||||
try:
|
||||
mode = current.lstat().st_mode
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if stat.S_ISLNK(mode):
|
||||
raise ValueError(f"staging path must not contain a symbolic link: {current}")
|
||||
if current != path and not stat.S_ISDIR(mode):
|
||||
raise ValueError(f"staging parent must be a directory: {current}")
|
||||
|
||||
|
||||
def ensure_stage_path(root: Path, path: Path) -> Path:
|
||||
root = root.absolute()
|
||||
path = path.absolute()
|
||||
try:
|
||||
relative = path.relative_to(root)
|
||||
except ValueError as error:
|
||||
raise ValueError("staging file escaped its private generation") from error
|
||||
current = root
|
||||
if current.is_symlink():
|
||||
raise ValueError("staging root must not be a symbolic link")
|
||||
for part in relative.parts:
|
||||
current /= part
|
||||
try:
|
||||
mode = current.lstat().st_mode
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if stat.S_ISLNK(mode):
|
||||
raise ValueError(f"staging path must not contain a symbolic link: {relative}")
|
||||
if current != path and not stat.S_ISDIR(mode):
|
||||
raise ValueError(f"staging parent must be a directory: {relative}")
|
||||
return path
|
||||
|
||||
|
||||
def stage_write(root: Path, path: Path, content: bytes) -> None:
|
||||
path = ensure_stage_path(root, path)
|
||||
atomic_write(path, content)
|
||||
ensure_stage_path(root, path)
|
||||
|
||||
|
||||
def stage_mkdir(root: Path, path: Path) -> None:
|
||||
path = ensure_stage_path(root, path)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
ensure_stage_path(root, path)
|
||||
if not path.is_dir():
|
||||
raise ValueError("staging path must be a directory")
|
||||
|
||||
|
||||
def validate_stage_tree(output: Path) -> None:
|
||||
for relative in (
|
||||
Path("current-api.sqlite3"), Path("current-api.sqlite3-journal"),
|
||||
Path("current-api.sqlite3-wal"), Path("current-api.sqlite3-shm"),
|
||||
Path("manifest.sqlite3"), Path("manifest.sqlite3-journal"),
|
||||
Path("manifest.sqlite3-wal"), Path("manifest.sqlite3-shm"),
|
||||
Path("request-contract.json"),
|
||||
):
|
||||
path = ensure_stage_path(output, output / relative)
|
||||
if path.exists() and not path.is_file():
|
||||
raise ValueError(f"staging checkpoint must be a regular file: {relative}")
|
||||
for relative in (Path(".work"), Path("documents")):
|
||||
root = ensure_stage_path(output, output / relative)
|
||||
if root.exists() and not root.is_dir():
|
||||
raise ValueError(f"staging path must be a directory: {relative}")
|
||||
if root.is_dir():
|
||||
for directory, directories, files in os.walk(root, followlinks=False):
|
||||
for name in directories + files:
|
||||
ensure_stage_path(output, Path(directory) / name)
|
||||
|
||||
|
||||
def validate_stage_root(output: Path, resume: bool) -> Path:
|
||||
requested = Path(output)
|
||||
if ".." in requested.parts:
|
||||
raise ValueError("staging output path must not contain '..'")
|
||||
destination = Path(os.path.abspath(requested))
|
||||
reject_symlink_components(destination)
|
||||
parent = destination.parent
|
||||
if not parent.is_dir():
|
||||
raise ValueError("staging output parent must already exist")
|
||||
for ancestor in (parent, *parent.parents):
|
||||
info = ancestor.lstat()
|
||||
mode = stat.S_IMODE(info.st_mode)
|
||||
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
||||
raise ValueError("staging output ancestors must be real directories")
|
||||
sticky_parent = bool(mode & stat.S_ISVTX)
|
||||
if mode & 0o022 and not sticky_parent:
|
||||
raise ValueError("staging output parent is not trusted")
|
||||
for relative in (Path("data/minjust-cbd"), Path("data/minjust-normalized")):
|
||||
legacy = (REPOSITORY_ROOT / relative).resolve()
|
||||
if destination == legacy or destination.is_relative_to(legacy) or legacy.is_relative_to(destination):
|
||||
raise ValueError("current API staging must not overlap the existing corpus")
|
||||
if destination.exists():
|
||||
if destination.is_symlink() or not destination.is_dir():
|
||||
raise ValueError("staging output must be a real directory")
|
||||
else:
|
||||
destination.mkdir(mode=0o700)
|
||||
root_info = destination.lstat()
|
||||
root_mode = stat.S_IMODE(root_info.st_mode)
|
||||
if (
|
||||
stat.S_ISLNK(root_info.st_mode)
|
||||
or not stat.S_ISDIR(root_info.st_mode)
|
||||
or root_info.st_uid != os.geteuid()
|
||||
or root_mode & 0o077
|
||||
or (root_mode & 0o700) != 0o700
|
||||
):
|
||||
raise ValueError("staging output must be a private directory owned by the current user")
|
||||
validate_stage_tree(destination)
|
||||
if any(destination.iterdir()) and not resume:
|
||||
raise ValueError("staging output is not empty; use --resume for this generation")
|
||||
contract_path = destination / "request-contract.json"
|
||||
if contract_path.exists():
|
||||
try:
|
||||
contract = json.loads(contract_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||
raise SourceChanged("staging generation request contract is invalid") from error
|
||||
if contract != REQUEST_CONTRACT:
|
||||
raise SourceChanged("staging generation uses a different request contract")
|
||||
else:
|
||||
if any(destination.iterdir()):
|
||||
raise SourceChanged("staging generation has no exact-edition request contract; start a new generation")
|
||||
stage_write(destination, contract_path, json_bytes(REQUEST_CONTRACT))
|
||||
return destination
|
||||
|
||||
|
||||
def stage_document(
|
||||
output: Path,
|
||||
alias: str,
|
||||
client: CurrentCbdClient,
|
||||
*,
|
||||
resume: bool = False,
|
||||
max_document_bytes: int = DEFAULT_DOCUMENT_BUDGET,
|
||||
) -> dict:
|
||||
"""Stage one document and its official editions without touching legacy roots."""
|
||||
if not alias or len(alias) > 64 or "/" in alias or "\\" in alias:
|
||||
raise ValueError("document code is invalid")
|
||||
if max_document_bytes <= 0:
|
||||
raise ValueError("document byte budget must be positive")
|
||||
output = validate_stage_root(output, resume)
|
||||
work_root = output / ".work" / "documents"
|
||||
target_root = output / "documents"
|
||||
connection = open_manifest(output / "current-api.sqlite3")
|
||||
raw_manifest = connect_manifest(output / "manifest.sqlite3")
|
||||
try:
|
||||
contract_sha = hashlib.sha256(json.dumps(REQUEST_CONTRACT, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
saved_contract = connection.execute("SELECT contract_sha256 FROM generation").fetchone()
|
||||
if saved_contract and saved_contract[0] != contract_sha:
|
||||
raise SourceChanged("checkpoint request contract changed; start a new staging generation")
|
||||
if not saved_contract:
|
||||
if connection.execute("SELECT COUNT(*) FROM documents").fetchone()[0]:
|
||||
raise SourceChanged("existing checkpoints have no request contract; start a new staging generation")
|
||||
with connection:
|
||||
connection.execute("INSERT INTO generation (contract_sha256) VALUES (?)", (contract_sha,))
|
||||
response = client.document(alias)
|
||||
source = response.value
|
||||
canonical = source["documentCode"]
|
||||
record_sha = response.sha256
|
||||
editions = source["editions"]
|
||||
previous = connection.execute(
|
||||
"SELECT canonical_id, response_sha256, expected_editions FROM documents WHERE alias = ?",
|
||||
(alias,),
|
||||
).fetchone()
|
||||
if previous and previous[1] is not None and (
|
||||
previous[0] != canonical or previous[1] != record_sha or previous[2] != len(editions)
|
||||
):
|
||||
raise SourceChanged("document metadata changed; start a new staging generation")
|
||||
working_document = work_root / str(canonical)
|
||||
final_document = target_root / str(canonical)
|
||||
ensure_stage_path(output, working_document)
|
||||
ensure_stage_path(output, final_document)
|
||||
if final_document.is_symlink():
|
||||
raise ValueError("staging target must not be a symbolic link")
|
||||
if final_document.exists() and not previous:
|
||||
raise ValueError("staging target already exists without a matching checkpoint")
|
||||
if final_document.exists():
|
||||
final_metadata = ensure_stage_path(output, final_document / "metadata.json")
|
||||
source_document = ensure_stage_path(output, final_document / "source-document.json")
|
||||
metadata = json.loads(final_metadata.read_text(encoding="utf-8"))
|
||||
if (
|
||||
metadata.get("SourceApi", {}).get("response_sha256") != record_sha
|
||||
or hashlib.sha256(source_document.read_bytes()).hexdigest() != record_sha
|
||||
):
|
||||
raise SourceChanged("published staging document does not match the current source record")
|
||||
for edition in editions:
|
||||
identifier = edition_id(edition)
|
||||
for request_language, archive_language, type_field in LANGUAGES:
|
||||
html_path = ensure_stage_path(
|
||||
output,
|
||||
final_document / "editions" / str(identifier) / f"{archive_language}.html",
|
||||
)
|
||||
checkpoint = connection.execute(
|
||||
"SELECT state, content_sha256 FROM languages WHERE canonical_id = ? AND edition_id = ? AND request_language = ?",
|
||||
(canonical, identifier, request_language),
|
||||
).fetchone()
|
||||
if not edition.get(type_field):
|
||||
if not checkpoint or checkpoint[0] != "unavailable":
|
||||
raise SourceError(f"published staging document has no checkpoint for edition {identifier} {request_language}")
|
||||
elif (
|
||||
not checkpoint
|
||||
or checkpoint[0] != "complete"
|
||||
or not html_path.is_file()
|
||||
or sha256_file(html_path) != checkpoint[1]
|
||||
):
|
||||
raise SourceError(f"published staging document failed verification for edition {identifier} {request_language}")
|
||||
document_sha = hash_tree(final_document)
|
||||
fetched_at = utc_now()
|
||||
manifest_row = raw_manifest.execute(
|
||||
"SELECT source_sha256 FROM documents WHERE code = ?", (canonical,)
|
||||
).fetchone()
|
||||
if manifest_row and manifest_row[0] != document_sha:
|
||||
raise SourceError("published staging document does not match its raw manifest")
|
||||
with raw_manifest:
|
||||
raw_manifest.execute(
|
||||
"INSERT OR IGNORE INTO documents (code, source_sha256, fetched_at) VALUES (?, ?, ?)",
|
||||
(canonical, document_sha, fetched_at),
|
||||
)
|
||||
raw_manifest.execute(
|
||||
"INSERT INTO errors (code, message, failed_at) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(code) DO UPDATE SET message=excluded.message, failed_at=excluded.failed_at",
|
||||
(canonical, "attachments and embedded media are not independently verified", fetched_at),
|
||||
)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE documents SET state='text_complete_incomplete_attachments', error=NULL, updated_at=? WHERE alias=?",
|
||||
(fetched_at, alias),
|
||||
)
|
||||
return {
|
||||
"alias": alias,
|
||||
"canonical_id": canonical,
|
||||
"editions": len(editions),
|
||||
"source_sha256": document_sha,
|
||||
"state": "text_complete_incomplete_attachments",
|
||||
"resumed": True,
|
||||
}
|
||||
stage_mkdir(output, working_document)
|
||||
document_metadata = map_document_metadata(source, alias, response.sha256)
|
||||
stage_write(output, working_document / "metadata.json", json_bytes(document_metadata))
|
||||
stage_write(output, working_document / "source-document.json", response.body)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"INSERT INTO documents (alias, canonical_id, response_sha256, expected_editions, state, updated_at) VALUES (?, ?, ?, ?, 'fetching', ?) "
|
||||
"ON CONFLICT(alias) DO UPDATE SET canonical_id=excluded.canonical_id, response_sha256=excluded.response_sha256, "
|
||||
"expected_editions=excluded.expected_editions, state='fetching', error=NULL, updated_at=excluded.updated_at",
|
||||
(alias, canonical, record_sha, len(editions), utc_now()),
|
||||
)
|
||||
for order, edition in enumerate(editions):
|
||||
identifier = edition_id(edition)
|
||||
existing = connection.execute(
|
||||
"SELECT source_order, response_sha256 FROM editions WHERE canonical_id = ? AND edition_id = ?",
|
||||
(canonical, identifier),
|
||||
).fetchone()
|
||||
if existing and existing != (order, hashlib.sha256(json_bytes(edition)).hexdigest()):
|
||||
raise SourceChanged(f"edition metadata changed for {identifier}")
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO editions (canonical_id, edition_id, source_order, response_sha256) VALUES (?, ?, ?, ?)",
|
||||
(canonical, identifier, order, hashlib.sha256(json_bytes(edition)).hexdigest()),
|
||||
)
|
||||
|
||||
incomplete = []
|
||||
for order, edition in enumerate(editions):
|
||||
identifier = edition_id(edition)
|
||||
directory = working_document / "editions" / str(identifier)
|
||||
stage_mkdir(output, directory)
|
||||
stage_write(output, directory / "metadata.json", json_bytes(map_edition_metadata(edition, order)))
|
||||
for request_language, archive_language, type_field in LANGUAGES:
|
||||
html_path = ensure_stage_path(output, directory / f"{archive_language}.html")
|
||||
checkpoint = connection.execute(
|
||||
"SELECT state, content_sha256, byte_count FROM languages WHERE canonical_id = ? AND edition_id = ? AND request_language = ?",
|
||||
(canonical, identifier, request_language),
|
||||
).fetchone()
|
||||
if checkpoint and checkpoint[0] == "complete":
|
||||
if not html_path.is_file() or sha256_file(html_path) != checkpoint[1]:
|
||||
raise SourceError(f"staged content hash mismatch for edition {identifier} {request_language}")
|
||||
continue
|
||||
if not edition.get(type_field):
|
||||
save_language_checkpoint(
|
||||
connection, canonical, identifier, request_language, archive_language,
|
||||
None, None, 0, "unavailable", "source metadata reports no language file",
|
||||
)
|
||||
continue
|
||||
try:
|
||||
language_response = client.edition(identifier, request_language)
|
||||
response_id = language_response.value.get("id")
|
||||
if isinstance(response_id, bool) or not isinstance(response_id, int) or response_id != identifier:
|
||||
raise SourceChanged(f"MinJust exact edition ID mismatch for {identifier}")
|
||||
for field in ("nameRus", "nameKyr"):
|
||||
if edition.get(field) is not None and language_response.value.get(field) != edition[field]:
|
||||
raise SourceChanged(f"MinJust exact edition metadata mismatch for {identifier}: {field}")
|
||||
content = language_response.value.get("contentRu")
|
||||
if not isinstance(content, str) or not content:
|
||||
raise SourceError(f"MinJust returned no content for edition {identifier} language {request_language}")
|
||||
encoded = content.encode("utf-8")
|
||||
completed_bytes = connection.execute(
|
||||
"SELECT COALESCE(SUM(byte_count), 0) FROM languages WHERE canonical_id = ? AND state = 'complete'",
|
||||
(canonical,),
|
||||
).fetchone()[0]
|
||||
if completed_bytes + len(encoded) > max_document_bytes:
|
||||
raise SourceError(f"document {canonical} exceeds its {max_document_bytes}-byte staging budget")
|
||||
response_metadata = {
|
||||
key: value
|
||||
for key, value in language_response.value.items()
|
||||
if key not in {"contentRu", "contentKg"}
|
||||
}
|
||||
response_metadata["requested_language"] = request_language
|
||||
response_metadata["archive_language"] = archive_language
|
||||
response_metadata["requested_edition_id"] = identifier
|
||||
response_metadata["exact"] = True
|
||||
response_metadata["response_sha256"] = language_response.sha256
|
||||
stage_write(
|
||||
output,
|
||||
directory / "source" / f"{request_language}.json",
|
||||
json_bytes(response_metadata),
|
||||
)
|
||||
stage_write(output, html_path, encoded)
|
||||
save_language_checkpoint(
|
||||
connection, canonical, identifier, request_language, archive_language,
|
||||
language_response.sha256, hashlib.sha256(encoded).hexdigest(), len(encoded), "complete", None,
|
||||
)
|
||||
except Exception as error:
|
||||
message = str(error)[:500]
|
||||
save_language_checkpoint(
|
||||
connection, canonical, identifier, request_language, archive_language,
|
||||
None, None, 0, "error", message,
|
||||
)
|
||||
incomplete.append({"edition_id": identifier, "language": request_language, "error": message})
|
||||
if isinstance(error, SourceChanged) or (isinstance(error, SourceError) and "budget" in message):
|
||||
raise
|
||||
|
||||
if incomplete:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE documents SET state='incomplete', error=?, updated_at=? WHERE alias=?",
|
||||
(json.dumps(incomplete, ensure_ascii=False), utc_now(), alias),
|
||||
)
|
||||
return {"alias": alias, "canonical_id": canonical, "editions": len(editions), "incomplete": incomplete}
|
||||
|
||||
working_metadata = ensure_stage_path(output, working_document / "metadata.json")
|
||||
metadata = json.loads(working_metadata.read_text(encoding="utf-8"))
|
||||
metadata["SourceCompleteness"] = {
|
||||
"text": "complete",
|
||||
"attachments": "not independently verified",
|
||||
}
|
||||
stage_write(output, working_metadata, json_bytes(metadata))
|
||||
stage_mkdir(output, target_root)
|
||||
ensure_stage_path(output, working_document)
|
||||
ensure_stage_path(output, final_document)
|
||||
if final_document.exists():
|
||||
raise ValueError("refusing to replace a document in this staging generation")
|
||||
os.replace(working_document, final_document)
|
||||
validate_stage_tree(output)
|
||||
document_sha = hash_tree(final_document)
|
||||
fetched_at = utc_now()
|
||||
with raw_manifest:
|
||||
raw_manifest.execute(
|
||||
"INSERT INTO documents (code, source_sha256, fetched_at) VALUES (?, ?, ?)",
|
||||
(canonical, document_sha, fetched_at),
|
||||
)
|
||||
raw_manifest.execute(
|
||||
"INSERT INTO errors (code, message, failed_at) VALUES (?, ?, ?)",
|
||||
(canonical, "attachments and embedded media are not independently verified", fetched_at),
|
||||
)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE documents SET state='text_complete_incomplete_attachments', updated_at=? WHERE alias=?",
|
||||
(fetched_at, alias),
|
||||
)
|
||||
return {
|
||||
"alias": alias,
|
||||
"canonical_id": canonical,
|
||||
"editions": len(editions),
|
||||
"source_sha256": document_sha,
|
||||
"state": "text_complete_incomplete_attachments",
|
||||
}
|
||||
except Exception as error:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"INSERT INTO documents (alias, state, error, updated_at) VALUES (?, 'error', ?, ?) "
|
||||
"ON CONFLICT(alias) DO UPDATE SET state='error', error=excluded.error, updated_at=excluded.updated_at",
|
||||
(alias, str(error)[:500], utc_now()),
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
raw_manifest.close()
|
||||
connection.close()
|
||||
|
||||
|
||||
def map_document_metadata(source: dict, alias: str, response_sha256: str) -> dict:
|
||||
canonical = source["documentCode"]
|
||||
return {
|
||||
"Code": canonical,
|
||||
"Name": {"Rus": source.get("nameRus"), "Kyr": source.get("nameKyr")},
|
||||
"Title": {"Rus": source.get("headerRu"), "Kyr": source.get("headerKg")},
|
||||
"Number": source.get("number"),
|
||||
"DateAdopted": source.get("dateAdopted"),
|
||||
"DateInclusion": source.get("dateInclusion"),
|
||||
"DateOfEntry": source.get("dateOfEntry"),
|
||||
"DatePublication": source.get("datePublication"),
|
||||
"DateRegistration": source.get("dateRegistration"),
|
||||
"IsPublicInRegister": source.get("isPublicInRegister"),
|
||||
"SourceStatus": source.get("status"),
|
||||
"SourceClassId": source.get("classId"),
|
||||
"SourceRefTypeId": source.get("refTypeId"),
|
||||
"SourceAuthorityIds": source.get("authoritiesId"),
|
||||
"SourcePublicationIds": source.get("sourcePublicationsId"),
|
||||
"SourceMetadata": {key: value for key, value in source.items() if key != "editions"},
|
||||
"SourceDocumentCode": alias,
|
||||
"SourceMediaStatus": "not independently verified",
|
||||
"SourceApi": {"endpoint": "/GetDocument", "response_sha256": response_sha256},
|
||||
}
|
||||
|
||||
|
||||
def map_edition_metadata(source: dict, source_order: int) -> dict:
|
||||
return {
|
||||
"Code": edition_id(source),
|
||||
"Name": {"Rus": source.get("nameRus"), "Kyr": source.get("nameKyr")},
|
||||
"SourceOrder": source_order,
|
||||
"SourceTextTypes": {
|
||||
"ru": source.get("textRusType"),
|
||||
"ky": source.get("textKyrType"),
|
||||
},
|
||||
"SourceMediaStatus": "not independently verified",
|
||||
"SourceMetadata": source,
|
||||
}
|
||||
|
||||
|
||||
def save_language_checkpoint(
|
||||
connection: sqlite3.Connection,
|
||||
canonical: int,
|
||||
edition: int,
|
||||
request_language: str,
|
||||
archive_language: str,
|
||||
response_sha256: str | None,
|
||||
content_sha256: str | None,
|
||||
byte_count: int,
|
||||
state: str,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"INSERT INTO languages (canonical_id, edition_id, request_language, archive_language, response_sha256, content_sha256, byte_count, state, error, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(canonical_id, edition_id, request_language) DO UPDATE SET "
|
||||
"archive_language=excluded.archive_language, response_sha256=excluded.response_sha256, content_sha256=excluded.content_sha256, "
|
||||
"byte_count=excluded.byte_count, state=excluded.state, error=excluded.error, updated_at=excluded.updated_at",
|
||||
(canonical, edition, request_language, archive_language, response_sha256, content_sha256, byte_count, state, error, utc_now()),
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def hash_tree(root: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(item for item in root.rglob("*") if item.is_file()):
|
||||
digest.update(path.relative_to(root).as_posix().encode())
|
||||
digest.update(b"\0")
|
||||
digest.update(sha256_file(path).encode("ascii"))
|
||||
digest.update(b"\0")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True, help="new, separate staging generation")
|
||||
parser.add_argument("--document-code", required=True, help="one official document alias or canonical code")
|
||||
parser.add_argument("--resume", action="store_true", help="resume only this staging generation")
|
||||
parser.add_argument("--requests-per-second", type=float, default=1)
|
||||
parser.add_argument("--timeout", type=int, default=60)
|
||||
parser.add_argument("--retries", type=int, default=5)
|
||||
parser.add_argument("--max-document-bytes", type=int, default=DEFAULT_DOCUMENT_BUDGET)
|
||||
parser.add_argument("--version", action="version", version=APP_VERSION)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
result = stage_document(
|
||||
arguments.output,
|
||||
arguments.document_code,
|
||||
CurrentCbdClient(arguments.requests_per_second, arguments.timeout, arguments.retries),
|
||||
resume=arguments.resume,
|
||||
max_document_bytes=arguments.max_document_bytes,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||
return int(result.get("state") != "text_complete_incomplete_attachments")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -23,9 +23,9 @@ from pathlib import Path
|
||||
from typing import Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
APP_VERSION = "0.9.3"
|
||||
APP_VERSION = "0.10.0"
|
||||
SCHEMA_VERSION = "1"
|
||||
NORMALIZER_VERSION = "1.0.0"
|
||||
NORMALIZER_VERSION = "1.1.0"
|
||||
LANGUAGES = ("ru", "ky")
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -376,13 +376,16 @@ def load_json(path: Path) -> dict:
|
||||
def edition_summary(edition_directory: Path, input_root: Path) -> dict:
|
||||
metadata = load_json(edition_directory / "metadata.json")
|
||||
languages = [language for language in LANGUAGES if (edition_directory / f"{language}.html").is_file()]
|
||||
return {
|
||||
summary = {
|
||||
"source_code": str(metadata.get("Code", edition_directory.name)),
|
||||
"name": bilingual(metadata.get("Name")),
|
||||
"source_type": clean_value(metadata.get("Type")),
|
||||
"available_languages": languages,
|
||||
"source_path": edition_directory.relative_to(input_root).as_posix(),
|
||||
}
|
||||
if "SourceOrder" in metadata:
|
||||
summary["source_order"] = metadata["SourceOrder"]
|
||||
return summary
|
||||
|
||||
|
||||
def normalize_document(
|
||||
@@ -406,6 +409,15 @@ def normalize_document(
|
||||
key=lambda path: (not path.name.isdigit(), int(path.name) if path.name.isdigit() else path.name),
|
||||
) if edition_root.is_dir() else []
|
||||
summaries = [edition_summary(path, input_root) for path in edition_directories]
|
||||
source_orders = [item.get("source_order") for item in summaries]
|
||||
if any(order is not None for order in source_orders):
|
||||
if any(not isinstance(order, int) or isinstance(order, bool) or order < 0 for order in source_orders):
|
||||
raise ValueError(f"Invalid source edition order in {document_directory}")
|
||||
if len(set(source_orders)) != len(source_orders):
|
||||
raise ValueError(f"Duplicate source edition order in {document_directory}")
|
||||
ordered = sorted(zip(source_orders, edition_directories, summaries), key=lambda item: item[0])
|
||||
edition_directories = [item[1] for item in ordered]
|
||||
summaries = [item[2] for item in ordered]
|
||||
available_languages = [language for language in LANGUAGES if any(language in item["available_languages"] for item in summaries)]
|
||||
normalized_metadata = clean_value(metadata)
|
||||
document = {
|
||||
|
||||
@@ -13,7 +13,7 @@ import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from search.auth import AuthError, AuthStore
|
||||
from search.auth import AuthError, AuthStore, can_manage_users
|
||||
from search.minjust_opensearch import APP_VERSION, request_json
|
||||
from search.catalog import CATALOGS, labels
|
||||
from search.query import build_search_query
|
||||
@@ -26,6 +26,16 @@ LANGUAGES = {"ru", "ky"}
|
||||
CODE = re.compile(r"^[0-9]+$")
|
||||
MAX_PAGE_SIZE = 100
|
||||
MAX_RESULT_WINDOW = 10_000
|
||||
STATIC_ASSETS = {
|
||||
"/static/favicon.svg": ("favicon.svg", "image/svg+xml"),
|
||||
"/static/favicon-32.png": ("favicon-32.png", "image/png"),
|
||||
"/static/fonts/noto-sans-cyrillic-ext.woff2": ("fonts/noto-sans-cyrillic-ext.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-sans-cyrillic.woff2": ("fonts/noto-sans-cyrillic.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-sans-latin.woff2": ("fonts/noto-sans-latin.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-serif-cyrillic-ext.woff2": ("fonts/noto-serif-cyrillic-ext.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-serif-cyrillic.woff2": ("fonts/noto-serif-cyrillic.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-serif-latin.woff2": ("fonts/noto-serif-latin.woff2", "font/woff2"),
|
||||
}
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
@@ -303,6 +313,17 @@ class Api:
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise ApiError(500, "review interface is unavailable") from error
|
||||
|
||||
@staticmethod
|
||||
def static_asset(route: str) -> tuple[str, bytes]:
|
||||
asset = STATIC_ASSETS.get(route)
|
||||
if asset is None:
|
||||
raise ApiError(404, "static asset not found")
|
||||
path = Path(__file__).with_name("static") / asset[0]
|
||||
try:
|
||||
return asset[1], path.read_bytes()
|
||||
except OSError as error:
|
||||
raise ApiError(500, "static asset is unavailable") from error
|
||||
|
||||
def handle(self, method: str, path: str, body: dict | None = None, user: dict | None = None) -> tuple[int, dict]:
|
||||
parsed = urllib.parse.urlsplit(path)
|
||||
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
||||
@@ -324,21 +345,21 @@ class Api:
|
||||
return 200, {"account": account, "session_token": token}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "GET" and parts == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "GET" and parts == ["admin", "users"]:
|
||||
return 200, {"users": self.auth_store.list_users()}
|
||||
if user and user["username"].casefold() == "admin" and method == "POST" and parts == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "POST" and parts == ["admin", "users"]:
|
||||
try:
|
||||
self.auth_store.create_user(body.get("username"), body.get("password"))
|
||||
return 201, {"ok": True}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
|
||||
if user and can_manage_users(user["username"]) and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
|
||||
try:
|
||||
self.auth_store.delete_user(parts[2])
|
||||
return 200, {"ok": True}
|
||||
except AuthError as error:
|
||||
raise ApiError(400, str(error)) from error
|
||||
if user and user["username"].casefold() == "admin" and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
|
||||
if user and can_manage_users(user["username"]) and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
|
||||
try:
|
||||
self.auth_store.reset_password(parts[2], body.get("password"))
|
||||
return 200, {"ok": True}
|
||||
@@ -374,7 +395,7 @@ def handler(api: Api):
|
||||
session = cookies.get("akyldash_session")
|
||||
token = session.value if session else None
|
||||
user = api.auth_store.user_for_token(token)
|
||||
if method == "GET" and route in {"/", "/review"}:
|
||||
if method == "GET" and route in {"/", "/review", "/users"}:
|
||||
body = api.review_page().encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -386,12 +407,24 @@ def handler(api: Api):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if method == "GET" and route in STATIC_ASSETS:
|
||||
content_type, body = api.static_asset(route)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "public, max-age=604800, immutable")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if not (method == "POST" and route in {"/auth/login", "/auth/logout"}):
|
||||
if not user:
|
||||
raise ApiError(401, "authentication required")
|
||||
if user["must_change_password"] and not (method == "POST" and route == "/auth/change-password") and not (method == "GET" and route == "/auth/me"):
|
||||
raise ApiError(403, "change password before continuing")
|
||||
if route.startswith("/admin/users") and user["username"].casefold() != "admin":
|
||||
if route.startswith("/admin/users") and not can_manage_users(user["username"]):
|
||||
raise ApiError(403, "admin account required")
|
||||
body = None
|
||||
if method in {"POST", "DELETE"}:
|
||||
@@ -470,7 +503,7 @@ def main() -> int:
|
||||
api = Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret, arguments.users_db, arguments.cookie_secure)
|
||||
if arguments.create_admin or arguments.reset_admin_password:
|
||||
users = api.auth_store.list_users()
|
||||
admin_exists = any(user["username"].casefold() == "admin" for user in users)
|
||||
admin_exists = any(can_manage_users(user["username"]) for user in users)
|
||||
if arguments.create_admin and users:
|
||||
parser.error("accounts already exist")
|
||||
if arguments.reset_admin_password and not admin_exists:
|
||||
|
||||
@@ -22,6 +22,10 @@ class AuthError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def can_manage_users(username: str) -> bool:
|
||||
return isinstance(username, str) and username.casefold() == "admin"
|
||||
|
||||
|
||||
class AuthStore:
|
||||
def __init__(self, path: Path | str):
|
||||
if path != ":memory:":
|
||||
@@ -106,7 +110,8 @@ class AuthStore:
|
||||
|
||||
@staticmethod
|
||||
def _public(user: sqlite3.Row) -> dict:
|
||||
return {"username": user["username"], "role": "Администратор", "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
|
||||
can_manage = can_manage_users(user["username"])
|
||||
return {"username": user["username"], "role": "Администратор" if can_manage else "Пользователь", "can_manage_users": can_manage, "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
|
||||
|
||||
def user_for_token(self, token: str | None) -> dict | None:
|
||||
if not token:
|
||||
@@ -151,7 +156,7 @@ class AuthStore:
|
||||
raise AuthError("login already exists") from error
|
||||
|
||||
def delete_user(self, username: str) -> None:
|
||||
if username.casefold() == "admin":
|
||||
if can_manage_users(username):
|
||||
raise AuthError("admin account cannot be deleted")
|
||||
with self._lock:
|
||||
self.connection.execute("DELETE FROM users WHERE username=?", (username,))
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Iterator
|
||||
|
||||
from search.catalog import authority_codes, legal_force_code, source_code
|
||||
|
||||
APP_VERSION = "0.11.3"
|
||||
APP_VERSION = "0.13.2"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||
|
||||
@@ -129,7 +129,12 @@ def bulk_pairs(
|
||||
editions = [path.name for path in edition_root.iterdir() if path.is_dir()] if edition_root.is_dir() else []
|
||||
if not editions:
|
||||
continue
|
||||
current_edition = max(editions, key=lambda value: (not value.isdigit(), int(value) if value.isdigit() else value))
|
||||
official_editions = document.get("editions") or []
|
||||
current_edition = (
|
||||
str(official_editions[-1]["source_code"])
|
||||
if official_editions
|
||||
else max(editions, key=lambda value: (not value.isdigit(), int(value) if value.isdigit() else value))
|
||||
)
|
||||
for fragment_path in sorted(directory.glob("editions/*/*/fragments.json")):
|
||||
edition_code, language = fragment_path.parts[-3:-1]
|
||||
values = read_json(fragment_path)
|
||||
@@ -460,7 +465,7 @@ def main() -> int:
|
||||
else:
|
||||
documents, fragments = export_bulk(arguments.input, arguments.output, arguments.index, arguments.limit)
|
||||
destination = arguments.output
|
||||
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.1.2")
|
||||
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.3")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<circle cx="24" cy="24" r="20" fill="#F7F2E8" stroke="#9D2633" stroke-width="3"/>
|
||||
<path d="M24 5v38M5 24h38M11 11l26 26M37 11 11 37" fill="none" stroke="#9D2633" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSans-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSerif-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,405 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from email.message import Message
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from ingestion import minjust_current
|
||||
from ingestion.minjust_current import (
|
||||
ApiResponse,
|
||||
CurrentCbdClient,
|
||||
SourceChanged,
|
||||
SourceError,
|
||||
stage_document,
|
||||
)
|
||||
|
||||
|
||||
def api_response(value):
|
||||
body = json.dumps(value, ensure_ascii=False).encode()
|
||||
import hashlib
|
||||
|
||||
return ApiResponse(value, body, hashlib.sha256(body).hexdigest())
|
||||
|
||||
|
||||
def document_record(alias="6", canonical=6, editions=None):
|
||||
return api_response(
|
||||
{
|
||||
"documentCode": canonical,
|
||||
"nameRus": "Закон",
|
||||
"nameKyr": "Мыйзам",
|
||||
"headerRu": "Заголовок",
|
||||
"headerKg": "Баш сөз",
|
||||
"number": 114,
|
||||
"dateAdopted": "1998-08-04",
|
||||
"classId": 1,
|
||||
"refTypeId": 10,
|
||||
"status": 1,
|
||||
"editions": editions if editions is not None else [
|
||||
{"id": 920492, "nameRus": "Редакция A", "nameKyr": "Редакция A", "textRusType": "docx", "textKyrType": "docx"},
|
||||
{"id": 920292, "nameRus": "Редакция B", "nameKyr": "Редакция B", "textRusType": "docx", "textKyrType": "docx"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CurrentCbdClientTests(unittest.TestCase):
|
||||
def test_rejects_legacy_roots_when_called_from_an_unrelated_working_directory(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
repository = base / "repository"
|
||||
elsewhere = base / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
legacy_roots = [
|
||||
repository / "data/minjust-cbd",
|
||||
repository / "data/minjust-normalized",
|
||||
]
|
||||
for legacy in legacy_roots:
|
||||
legacy.mkdir(parents=True)
|
||||
# Root CI uses umask 000, so make the trusted fixture's parent permissions explicit.
|
||||
for directory in (repository, repository / "data", *legacy_roots):
|
||||
directory.chmod(0o755)
|
||||
original_cwd = Path.cwd()
|
||||
client = Mock()
|
||||
try:
|
||||
os.chdir(elsewhere)
|
||||
with patch.object(minjust_current, "REPOSITORY_ROOT", repository):
|
||||
for legacy in legacy_roots:
|
||||
with self.subTest(legacy=legacy), self.assertRaisesRegex(ValueError, "must not overlap"):
|
||||
stage_document(legacy / "staging", "6", client)
|
||||
self.assertFalse((legacy / "staging").exists())
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
client.document.assert_not_called()
|
||||
|
||||
def test_requires_private_owned_staging_root(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "shared"
|
||||
output.mkdir(mode=0o755)
|
||||
output.chmod(0o755)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "private directory"):
|
||||
stage_document(output, "6", Mock())
|
||||
|
||||
def test_resume_rejects_symlinked_checkpoints_and_document_paths(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
targets = [
|
||||
("request-contract.json", False),
|
||||
("current-api.sqlite3", False),
|
||||
("manifest.sqlite3", False),
|
||||
(".work/documents/6", True),
|
||||
(".work/documents/6/editions/920492/ru.html", False),
|
||||
("documents/6", True),
|
||||
]
|
||||
client = Mock()
|
||||
for index, (relative, directory) in enumerate(targets):
|
||||
with self.subTest(relative=relative):
|
||||
output = base / f"generation-{index}"
|
||||
output.mkdir(mode=0o700)
|
||||
contract = output / "request-contract.json"
|
||||
if relative != "request-contract.json":
|
||||
contract.write_text(json.dumps(minjust_current.REQUEST_CONTRACT), encoding="utf-8")
|
||||
outside = base / f"outside-{index}"
|
||||
if directory:
|
||||
outside.mkdir()
|
||||
else:
|
||||
outside.write_text("preserve", encoding="utf-8")
|
||||
link = output / relative
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
link.symlink_to(outside, target_is_directory=directory)
|
||||
except (NotImplementedError, OSError):
|
||||
self.skipTest("symlinks are unavailable")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "symbolic link"):
|
||||
stage_document(output, "6", client, resume=True)
|
||||
|
||||
client.document.assert_not_called()
|
||||
if not directory:
|
||||
self.assertEqual(outside.read_text(encoding="utf-8"), "preserve")
|
||||
else:
|
||||
self.assertEqual(list(outside.iterdir()), [])
|
||||
|
||||
def test_respects_retry_after_and_retries_server_errors(self):
|
||||
headers = Message()
|
||||
headers["Retry-After"] = "7"
|
||||
response = Mock()
|
||||
response.read.return_value = b"{}"
|
||||
response.__enter__ = Mock(return_value=response)
|
||||
response.__exit__ = Mock(return_value=False)
|
||||
opener = Mock(side_effect=[
|
||||
urllib.error.HTTPError("https://example.test", 429, "", headers, None),
|
||||
urllib.error.HTTPError("https://example.test", 503, "", Message(), None),
|
||||
response,
|
||||
])
|
||||
delays = []
|
||||
|
||||
CurrentCbdClient(opener=opener, sleep=delays.append, clock=lambda: 0, retries=3).request_json("/GetEdition")
|
||||
|
||||
self.assertIn(7.0, delays)
|
||||
self.assertIn(2, delays)
|
||||
self.assertEqual(opener.call_count, 3)
|
||||
|
||||
def test_enforces_request_interval(self):
|
||||
now = [0.0]
|
||||
starts = []
|
||||
response = Mock()
|
||||
response.read.return_value = b"{}"
|
||||
response.__enter__ = Mock(return_value=response)
|
||||
response.__exit__ = Mock(return_value=False)
|
||||
|
||||
def opener(_request, timeout):
|
||||
starts.append(now[0])
|
||||
return response
|
||||
|
||||
client = CurrentCbdClient(
|
||||
opener=opener,
|
||||
clock=lambda: now[0],
|
||||
sleep=lambda delay: now.__setitem__(0, now[0] + delay),
|
||||
)
|
||||
client.request_json("/GetEdition")
|
||||
client.request_json("/GetEdition")
|
||||
|
||||
self.assertGreaterEqual(starts[1] - starts[0], 1.0)
|
||||
|
||||
def test_rejects_oversized_response(self):
|
||||
response = Mock()
|
||||
response.read.return_value = b"x" * 11
|
||||
response.__enter__ = Mock(return_value=response)
|
||||
response.__exit__ = Mock(return_value=False)
|
||||
with self.assertRaisesRegex(SourceError, "exceeds 10 bytes"):
|
||||
CurrentCbdClient(opener=lambda *_args, **_kwargs: response, max_response_bytes=10).request_json("/GetEdition")
|
||||
|
||||
def test_get_edition_uses_exact_query_and_checks_requested_id(self):
|
||||
response = Mock()
|
||||
response.read.return_value = json.dumps({"id": 920292, "contentRu": "text", "contentKg": None}).encode()
|
||||
response.__enter__ = Mock(return_value=response)
|
||||
response.__exit__ = Mock(return_value=False)
|
||||
requests = []
|
||||
|
||||
def opener(request, timeout):
|
||||
requests.append(request)
|
||||
return response
|
||||
|
||||
result = CurrentCbdClient(opener=opener, sleep=lambda _delay: None).edition(920292, "kg")
|
||||
|
||||
self.assertEqual(requests[0].full_url, "https://cbd.minjust.gov.kg/api/v1/GetEdition?editionId=920292&lang=kg&exact=true")
|
||||
self.assertEqual(requests[0].method, "GET")
|
||||
self.assertEqual(result.value["id"], 920292)
|
||||
|
||||
def test_rejects_response_id_that_does_not_match_exact_request(self):
|
||||
response = Mock()
|
||||
response.read.return_value = json.dumps({"id": 920492, "contentRu": "text", "contentKg": None}).encode()
|
||||
response.__enter__ = Mock(return_value=response)
|
||||
response.__exit__ = Mock(return_value=False)
|
||||
|
||||
with self.assertRaisesRegex(SourceChanged, "does not match requested ID"):
|
||||
CurrentCbdClient(opener=lambda *_args, **_kwargs: response).edition(920292, "ru")
|
||||
|
||||
def test_stages_alias_mapping_language_quirk_and_official_edition_order(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
output = base / "generation"
|
||||
old_raw = base / "legacy" / "documents" / "6" / "sentinel"
|
||||
old_raw.parent.mkdir(parents=True)
|
||||
old_raw.write_text("preserve", encoding="utf-8")
|
||||
|
||||
class Client:
|
||||
def document(self, alias):
|
||||
self.alias = alias
|
||||
return document_record(alias=alias, canonical=6)
|
||||
|
||||
def edition(self, identifier, language):
|
||||
text = "<p>Russian</p>" if language == "ru" else "<p>Кыргызча</p>"
|
||||
name = "Редакция A" if identifier == 920492 else "Редакция B"
|
||||
return api_response({
|
||||
"id": identifier, "nameRus": name, "nameKyr": name,
|
||||
"contentRu": text, "contentKg": None,
|
||||
})
|
||||
|
||||
client = Client()
|
||||
result = stage_document(output, "6", client)
|
||||
raw = output / "documents" / "6"
|
||||
|
||||
self.assertEqual(result["state"], "text_complete_incomplete_attachments")
|
||||
self.assertEqual((raw / "editions/920492/ru.html").read_text(), "<p>Russian</p>")
|
||||
self.assertEqual((raw / "editions/920492/ky.html").read_text(), "<p>Кыргызча</p>")
|
||||
first = json.loads((raw / "editions/920492/metadata.json").read_text())
|
||||
second = json.loads((raw / "editions/920292/metadata.json").read_text())
|
||||
self.assertEqual((first["SourceOrder"], second["SourceOrder"]), (0, 1))
|
||||
language_meta = json.loads((raw / "editions/920292/source/ru.json").read_text())
|
||||
self.assertEqual((language_meta["requested_edition_id"], language_meta["exact"]), (920292, True))
|
||||
with sqlite3.connect(output / "current-api.sqlite3") as connection:
|
||||
self.assertTrue(connection.execute("SELECT contract_sha256 FROM generation").fetchone()[0])
|
||||
self.assertEqual(old_raw.read_text(), "preserve")
|
||||
with sqlite3.connect(output / "current-api.sqlite3") as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT request_language, archive_language, state FROM languages ORDER BY request_language, edition_id"
|
||||
).fetchall()
|
||||
self.assertEqual(rows, [("kg", "ky", "complete")] * 2 + [("ru", "ru", "complete")] * 2)
|
||||
|
||||
def test_resolves_official_alias_to_canonical_staging_directory(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "generation"
|
||||
|
||||
class Client:
|
||||
def document(self, alias):
|
||||
return document_record(alias=alias, canonical=205064, editions=[])
|
||||
|
||||
result = stage_document(output, "1-4", Client())
|
||||
|
||||
self.assertEqual(result["canonical_id"], 205064)
|
||||
self.assertTrue((output / "documents/205064/metadata.json").is_file())
|
||||
self.assertFalse((output / "documents/1-4").exists())
|
||||
|
||||
def test_rejects_non_exact_edition_response_id_without_publishing(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "generation"
|
||||
|
||||
class Client:
|
||||
def document(self, _alias):
|
||||
return document_record(editions=[{"id": 920292, "textRusType": "docx", "textKyrType": "docx"}])
|
||||
|
||||
def edition(self, _identifier, language):
|
||||
return api_response({"id": 920492, "contentRu": f"<{language} edition>", "contentKg": None})
|
||||
|
||||
with self.assertRaisesRegex(SourceChanged, "exact edition ID mismatch"):
|
||||
stage_document(output, "6", Client())
|
||||
|
||||
self.assertFalse((output / "documents/6").exists())
|
||||
with sqlite3.connect(output / "current-api.sqlite3") as connection:
|
||||
self.assertEqual(connection.execute("SELECT state FROM languages").fetchone()[0], "error")
|
||||
|
||||
def test_rejects_exact_edition_metadata_contradiction(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "generation"
|
||||
client = CurrentCbdClient()
|
||||
client.document = lambda _alias: document_record(editions=[{
|
||||
"id": 920292, "nameRus": "04.08.1998", "nameKyr": "04.08.1998",
|
||||
"textRusType": "docx", "textKyrType": "docx",
|
||||
}])
|
||||
client.request_json = lambda *_args, **_kwargs: api_response({
|
||||
"id": 920292, "nameRus": "13.04.2017", "nameKyr": "13.04.2017",
|
||||
"contentRu": "<wrong edition>", "contentKg": None,
|
||||
})
|
||||
|
||||
with self.assertRaisesRegex(SourceChanged, "metadata mismatch"):
|
||||
stage_document(output, "6", client)
|
||||
|
||||
self.assertFalse((output / "documents/6").exists())
|
||||
|
||||
def test_recovers_atomic_publish_before_manifest_checkpoint(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "generation"
|
||||
|
||||
class Client:
|
||||
def document(self, _alias):
|
||||
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
|
||||
|
||||
def edition(self, identifier, language):
|
||||
return api_response({"id": identifier, "contentRu": f"<{language}>", "contentKg": None})
|
||||
|
||||
client = Client()
|
||||
first = stage_document(output, "6", client)
|
||||
with sqlite3.connect(output / "current-api.sqlite3") as connection:
|
||||
connection.execute("UPDATE documents SET state='fetching' WHERE alias='6'")
|
||||
with sqlite3.connect(output / "manifest.sqlite3") as connection:
|
||||
connection.execute("DELETE FROM documents WHERE code=6")
|
||||
connection.execute("DELETE FROM errors WHERE code=6")
|
||||
|
||||
resumed = stage_document(output, "6", client, resume=True)
|
||||
|
||||
self.assertEqual(resumed["source_sha256"], first["source_sha256"])
|
||||
self.assertTrue(resumed["resumed"])
|
||||
with sqlite3.connect(output / "manifest.sqlite3") as connection:
|
||||
self.assertEqual(connection.execute("SELECT COUNT(*) FROM documents WHERE code=6").fetchone()[0], 1)
|
||||
self.assertEqual(connection.execute("SELECT COUNT(*) FROM errors WHERE code=6").fetchone()[0], 1)
|
||||
|
||||
def test_retries_after_document_metadata_request_failed_before_checkpoint(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "generation"
|
||||
|
||||
class Client:
|
||||
failed = False
|
||||
|
||||
def document(self, _alias):
|
||||
if not self.failed:
|
||||
self.failed = True
|
||||
raise SourceError("temporary DNS failure")
|
||||
return document_record(editions=[])
|
||||
|
||||
client = Client()
|
||||
with self.assertRaisesRegex(SourceError, "DNS"):
|
||||
stage_document(output, "6", client)
|
||||
|
||||
result = stage_document(output, "6", client, resume=True)
|
||||
|
||||
self.assertEqual(result["canonical_id"], 6)
|
||||
with sqlite3.connect(output / "current-api.sqlite3") as connection:
|
||||
row = connection.execute(
|
||||
"SELECT canonical_id, response_sha256, expected_editions FROM documents WHERE alias='6'"
|
||||
).fetchone()
|
||||
self.assertEqual(row, (6, document_record(editions=[]).sha256, 0))
|
||||
|
||||
def test_does_not_resume_generation_without_exact_request_contract(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "legacy-generation"
|
||||
output.mkdir(mode=0o700)
|
||||
sentinel = output / "old-response.json"
|
||||
sentinel.write_text("preserve", encoding="utf-8")
|
||||
client = Mock()
|
||||
|
||||
with self.assertRaisesRegex(SourceChanged, "no exact-edition request contract"):
|
||||
stage_document(output, "6", client, resume=True)
|
||||
|
||||
client.document.assert_not_called()
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve")
|
||||
|
||||
def test_incomplete_translation_resumes_without_rewriting_successful_language(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "generation"
|
||||
|
||||
class Client:
|
||||
failed = False
|
||||
|
||||
def document(self, _alias):
|
||||
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
|
||||
|
||||
def edition(self, identifier, language):
|
||||
if language == "kg" and not self.failed:
|
||||
self.failed = True
|
||||
raise SourceError("simulated interrupted response")
|
||||
return api_response({"id": identifier, "contentRu": f"<{language}>", "contentKg": None})
|
||||
|
||||
client = Client()
|
||||
first = stage_document(output, "6", client)
|
||||
self.assertIn("incomplete", first)
|
||||
self.assertFalse((output / "documents/6").exists())
|
||||
second = stage_document(output, "6", client, resume=True)
|
||||
self.assertEqual(second["state"], "text_complete_incomplete_attachments")
|
||||
self.assertEqual((output / "documents/6/editions/920492/ru.html").read_text(), "<ru>")
|
||||
self.assertEqual((output / "documents/6/editions/920492/ky.html").read_text(), "<kg>")
|
||||
self.assertEqual(client.failed, True)
|
||||
|
||||
def test_refuses_budget_overrun_without_publishing_partial_document(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "generation"
|
||||
|
||||
class Client:
|
||||
def document(self, _alias):
|
||||
return document_record(editions=[{"id": 920492, "textRusType": "docx", "textKyrType": "docx"}])
|
||||
|
||||
def edition(self, identifier, _language):
|
||||
return api_response({"id": identifier, "contentRu": "123456", "contentKg": None})
|
||||
|
||||
with self.assertRaisesRegex(SourceError, "budget"):
|
||||
stage_document(output, "6", Client(), max_document_bytes=5)
|
||||
self.assertFalse((output / "documents/6").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -86,6 +86,47 @@ class MinjustNormalizationTest(unittest.TestCase):
|
||||
empty = json.loads((output / "documents/1/editions/40/edition.json").read_text(encoding="utf-8"))
|
||||
self.assertFalse(empty["quality"]["has_html"])
|
||||
|
||||
def test_preserves_official_edition_order_instead_of_sorting_ids(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
source = base / "source"
|
||||
output = base / "normalized"
|
||||
document = self.document(source, 6)
|
||||
self.edition(document, 920492, {"ru": "<p>Первая редакция</p>"})
|
||||
self.edition(document, 920292, {"ru": "<p>Последняя редакция</p>"})
|
||||
for code, order in ((920492, 0), (920292, 1)):
|
||||
metadata_path = document / "editions" / str(code) / "metadata.json"
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
metadata["SourceOrder"] = order
|
||||
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
normalize_archive(source, output)
|
||||
normalized = json.loads((output / "documents/6/document.json").read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual([item["source_code"] for item in normalized["editions"]], ["920492", "920292"])
|
||||
self.assertEqual([item["source_order"] for item in normalized["editions"]], [0, 1])
|
||||
|
||||
def test_rejects_partial_source_edition_order(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
source = base / "source"
|
||||
output = base / "normalized"
|
||||
document = self.document(source, 6)
|
||||
self.edition(document, 10, {"ru": "<p>Один</p>"})
|
||||
self.edition(document, 20, {"ru": "<p>Два</p>"})
|
||||
metadata_path = document / "editions/10/metadata.json"
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
metadata["SourceOrder"] = 0
|
||||
metadata_path.write_text(json.dumps(metadata), encoding="utf-8")
|
||||
|
||||
with self.assertLogs("normalization.minjust_cbd", level="ERROR"):
|
||||
result = normalize_archive(source, output)
|
||||
|
||||
self.assertEqual((result.failed, result.normalized), (1, 0))
|
||||
with sqlite3.connect(output / "manifest.sqlite3") as connection:
|
||||
error = connection.execute("SELECT error FROM documents WHERE code='6'").fetchone()[0]
|
||||
self.assertIn("Invalid source edition order", error)
|
||||
|
||||
def test_continues_after_bad_document_and_clears_repaired_error(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
|
||||
@@ -25,6 +25,50 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
self.assertEqual(legal_force_code({"ru": "Указ", "ky": "Жарлыгы"}), "subordinate")
|
||||
self.assertEqual(source_code("document_type", {"ru": "устав"}), "charter")
|
||||
|
||||
def test_export_marks_last_official_edition_current_even_when_ids_are_out_of_order(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
input_root = root / "normalized"
|
||||
document_root = input_root / "documents/6"
|
||||
document_root.mkdir(parents=True)
|
||||
with closing(sqlite3.connect(input_root / "manifest.sqlite3")) as connection:
|
||||
with connection:
|
||||
connection.execute("CREATE TABLE documents (code TEXT, state TEXT)")
|
||||
connection.execute("INSERT INTO documents VALUES ('6', 'success')")
|
||||
(document_root / "document.json").write_text(
|
||||
json.dumps({"schema_version": "1", "source_code": "6", "editions": [
|
||||
{"source_code": "920492", "source_order": 0},
|
||||
{"source_code": "920292", "source_order": 1},
|
||||
]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
for edition in ("920292", "920492"):
|
||||
text = f"Текст {edition}"
|
||||
fragment_path = document_root / f"editions/{edition}/ru/fragments.json"
|
||||
fragment_path.parent.mkdir(parents=True)
|
||||
fragment_path.write_text(json.dumps([{
|
||||
"id": f"document:6:edition:{edition}:lang:ru:fragment:1",
|
||||
"document_code": "6",
|
||||
"edition_code": edition,
|
||||
"language": "ru",
|
||||
"position": 1,
|
||||
"type": "paragraph",
|
||||
"text": text,
|
||||
"text_sha256": hashlib.sha256(text.encode()).hexdigest(),
|
||||
"source_path": f"documents/6/editions/{edition}/ru.html",
|
||||
"source_sha256": "b" * 64,
|
||||
}]), encoding="utf-8")
|
||||
|
||||
output = root / "bulk.ndjson"
|
||||
self.assertEqual(export_bulk(input_root, output, "test-index"), (1, 2))
|
||||
lines = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()]
|
||||
flags = {
|
||||
lines[index]["index"]["_id"].split(":")[3]: lines[index + 1]["is_current_edition"]
|
||||
for index in range(0, len(lines), 2)
|
||||
}
|
||||
|
||||
self.assertEqual(flags, {"920292": True, "920492": False})
|
||||
|
||||
def test_switches_alias_atomically_after_successful_load(self):
|
||||
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": True}) as request:
|
||||
switch_alias("http://127.0.0.1:9200/", "akyldash-fragments-v2", "akyldash-fragments-current")
|
||||
|
||||
@@ -4,9 +4,9 @@ import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from search.api import Api, ApiError
|
||||
from search.api import Api, ApiError, handler
|
||||
|
||||
|
||||
class SearchApiTest(unittest.TestCase):
|
||||
@@ -24,6 +24,69 @@ class SearchApiTest(unittest.TestCase):
|
||||
(edition / "ru/content.txt").write_text("Текст\n", encoding="utf-8")
|
||||
return Api("http://opensearch:9200", "current", root)
|
||||
|
||||
def test_static_font_assets_are_allowlisted(self):
|
||||
content_type, body = Api.static_asset("/static/fonts/noto-sans-cyrillic-ext.woff2")
|
||||
self.assertEqual(content_type, "font/woff2")
|
||||
self.assertTrue(body.startswith(b"wOF2"))
|
||||
content_type, body = Api.static_asset("/static/favicon.svg")
|
||||
self.assertEqual(content_type, "image/svg+xml")
|
||||
self.assertIn(b"<svg", body)
|
||||
content_type, body = Api.static_asset("/static/favicon-32.png")
|
||||
self.assertEqual(content_type, "image/png")
|
||||
self.assertTrue(body.startswith(b"\x89PNG\r\n\x1a\n"))
|
||||
with self.assertRaisesRegex(ApiError, "static asset not found"):
|
||||
Api.static_asset("/static/fonts/../../DESIGN.md")
|
||||
|
||||
def test_users_screen_is_served_without_exposing_admin_api(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
request_handler = handler(api)
|
||||
|
||||
def get(path):
|
||||
request = request_handler.__new__(request_handler)
|
||||
request.path = path
|
||||
request.headers = {}
|
||||
request.wfile = io.BytesIO()
|
||||
request.send_response = Mock()
|
||||
request.send_header = Mock()
|
||||
request.end_headers = Mock()
|
||||
request.do_GET()
|
||||
return request
|
||||
|
||||
users_page = get("/users")
|
||||
users_page.send_response.assert_called_once_with(200)
|
||||
self.assertIn(b'id="users-page"', users_page.wfile.getvalue())
|
||||
admin_api = get("/admin/users")
|
||||
admin_api.send_response.assert_called_once_with(401)
|
||||
|
||||
def test_account_capability_matches_the_admin_users_api_guard(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
api.auth_store.create_admin("Fixture-only-admin-password-2026")
|
||||
admin_token, _ = api.auth_store.login("admin", "Fixture-only-admin-password-2026")
|
||||
api.auth_store.create_user("fixture-user", "Fixture-only-temporary-password-2026")
|
||||
api.auth_store.change_password("fixture-user", "Fixture-only-temporary-password-2026", "Fixture-only-user-password-2026")
|
||||
user_token, _ = api.auth_store.login("fixture-user", "Fixture-only-user-password-2026")
|
||||
request_handler = handler(api)
|
||||
|
||||
def get(path, token):
|
||||
request = request_handler.__new__(request_handler)
|
||||
request.path = path
|
||||
request.headers = {"Cookie": f"akyldash_session={token}"}
|
||||
request.wfile = io.BytesIO()
|
||||
request.send_response = Mock()
|
||||
request.send_header = Mock()
|
||||
request.end_headers = Mock()
|
||||
request.do_GET()
|
||||
return request
|
||||
|
||||
admin_account = json.loads(get("/auth/me", admin_token).wfile.getvalue())["account"]
|
||||
user_account = json.loads(get("/auth/me", user_token).wfile.getvalue())["account"]
|
||||
self.assertEqual((admin_account["role"], admin_account["can_manage_users"]), ("Администратор", True))
|
||||
self.assertEqual((user_account["role"], user_account["can_manage_users"]), ("Пользователь", False))
|
||||
get("/admin/users", admin_token).send_response.assert_called_once_with(200)
|
||||
get("/admin/users", user_token).send_response.assert_called_once_with(403)
|
||||
|
||||
def test_search_pagination_filters_and_highlight(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
|
||||
@@ -45,4 +45,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.0 · Frontend v0.2.3
|
||||
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 157 KiB |
@@ -25,8 +25,13 @@ rootless Docker setup and user service use `.config/act-runner` as writable
|
||||
`.config/act-runner/.local/share` as `XDG_DATA_HOME`. This keeps Docker config
|
||||
and its systemd user unit in the existing `.config` tree without granting
|
||||
write access to the protected home.
|
||||
rootless Docker daemon
|
||||
and runner user are separate from production Docker; `codex` is not added to
|
||||
The runner systemd unit uses `ProtectHome=read-only` so `/run/user` remains
|
||||
visible; `ReadWritePaths` retains the existing `/run/user/$uid` writable
|
||||
carve-out for the runner's rootless runtime. `ProtectHome=yes` would hide
|
||||
`/run/user` and break the configured runtime and Docker socket paths. See the
|
||||
[systemd.exec documentation](https://www.freedesktop.org/software/systemd/man/255/systemd.exec.html#ProtectHome=).
|
||||
The rootless Docker daemon and runner user are separate from production Docker;
|
||||
`codex` is not added to
|
||||
sudo or Docker groups. Jobs do not receive the rootless Docker socket, the
|
||||
production Docker socket, production environment/secrets, or arbitrary host
|
||||
bind mounts. The runner account has no access to `/etc/akyldash` or the
|
||||
|
||||
@@ -96,6 +96,57 @@ From the VPS, a metadata-only HTTPS check to `CheckAvailable` failed at the
|
||||
HTTP/2 transport layer (`curl` exit 92, `INTERNAL_ERROR`, HTTP status `000`,
|
||||
TLS verification `0`). An HTTP/1.1 retry was not completed.
|
||||
|
||||
## Current API and bounded code 6 recovery (2026-10-20)
|
||||
|
||||
The current official site uses `POST /api/v1/GetDocuments` (page size 12),
|
||||
`GET /api/v1/GetDocument?DocumentCode=...`, and
|
||||
`GET /api/v1/GetEdition?editionId=...&lang=ru|kg`. A bounded first-page probe
|
||||
reported 210,639 records; this recovery does not enumerate the full list. The
|
||||
official alias `1-4` resolves to canonical code `205064`. Code `6` is present
|
||||
and has 201 editions. The edition order in `GetDocument` is authoritative;
|
||||
numeric IDs do not define currentness.
|
||||
|
||||
For `lang=kg`, the API returns Kyrgyz HTML in `contentRu` and `contentKg=null`.
|
||||
The bounded adapter maps that response by the requested language to archive
|
||||
`ky`, preserves the response and checkpoints, limits requests to one per
|
||||
second, caps JSON responses at 16 MiB, and caps one document's text at 4 GiB.
|
||||
It writes only to a separate generation under `/tmp`; it neither resumes nor
|
||||
overwrites the existing ignored corpus.
|
||||
|
||||
Without `exact=true`, a request for `920292` returned response field
|
||||
`id=920492` despite different text length/hash. Two exact probes for `920292`
|
||||
and `920492` returned matching IDs and names/dates (`04.08.1998` and
|
||||
`13.04.2017`) matching `GetDocument`; their RU text lengths/hashes were
|
||||
485,487 / `2b51c2ff4e9b91cbe2a7ed052a5a9ed9150bda993fc15c120ac948580926b718`
|
||||
and 1,139,908 / `e5f0fdbb47766e5a5dbb971183d765e4c1dcaf9c969a218a84206244ce9132e8`.
|
||||
The adapter now requires `exact=true`, returned-ID equality, and matching RU/KY
|
||||
names before a language checkpoint can complete. Its versioned request
|
||||
contract is part of both staging metadata and the SQLite resume identity;
|
||||
pre-contract generations cannot be resumed.
|
||||
|
||||
The adapter does not independently validate attachments or embedded media.
|
||||
Even after the text stage completes, the raw manifest records this as an
|
||||
unresolved error. Do not merge this single document into the existing corpus,
|
||||
claim corpus completeness, or transfer it to production without a separate
|
||||
review and comparison. The prior no-exact pass at
|
||||
`/tmp/akyldash-current-code6.ZAMC37u4ex` is marked quarantined in
|
||||
`qualification.json`; its 240 text checkpoints and 304,493,520 bytes are
|
||||
retained as unverified, with no document directory published. Its preserved
|
||||
work-tree SHA-256 is
|
||||
`1e874c1d7fab1e00e7e45ff1b16422ea474e22ee13c901ac9a0612f47aa5d24a`. It must
|
||||
not be reused or normalized.
|
||||
|
||||
The exact-only generation at `/tmp/akyldash-current-code6-exact.L4k3AvnOlP`
|
||||
contains 201 editions and 402 complete RU/KY text checkpoints (604,377,885
|
||||
raw bytes). The raw manifest passes `quick_check`; its stored source SHA-256
|
||||
matches the staged document tree at
|
||||
`81d8e1a53b2ebef61f08b56d04b3f0a80077d587a4fadae0152bb4b7f13055ea`. Separate
|
||||
normalization at `/tmp/akyldash-current-code6-exact.L4k3AvnOlP-normalized`
|
||||
passes `quick_check`, records normalizer `1.1.0`, and preserves first/last
|
||||
edition order `920292` (`04.08.1998`) to `920492` (`13.04.2017`). The normalized
|
||||
generation is about 2.05 GB. Neither temporary generation has been merged into
|
||||
the existing corpus. Attachments and embedded media remain unverified.
|
||||
|
||||
The first full pass (run `b4a4556e30cc464d90bc8d75ef1673e9`) stopped before
|
||||
validation with `Python int too large to convert to SQLite INTEGER` after
|
||||
2,120,420 raw entries and 397,856 normalized entries. This was an inventory
|
||||
|
||||
@@ -63,8 +63,8 @@ protected file metadata remain unverified.
|
||||
unverified.
|
||||
- No public application listener is observed on TCP `80`/`443`. Docker
|
||||
container/volume state still requires the root audit. Repository Compose describes
|
||||
Caddy `2.11.6` pinned by digest, Python Search API `0.11.3`, SQLite
|
||||
account/review data, and OpenSearch `3.7.0` with `analysis-icu`.
|
||||
Caddy `2.11.6` pinned by digest, Python Search API `0.12.2` in this branch,
|
||||
SQLite account/review data, and OpenSearch `3.7.0` with `analysis-icu`.
|
||||
- The target publishes only Caddy on TCP `80`/`443`. Backend and Caddy share a
|
||||
proxy network; OpenSearch and backend share a separate internal network, and
|
||||
backend has no host port. Caddy certificate storage, OpenSearch, and
|
||||
@@ -89,6 +89,15 @@ protected file metadata remain unverified.
|
||||
`status` helper, guarded rollback helper, and installer with exact sudoers
|
||||
command rules. None is installed on the VPS. CI contains no production
|
||||
deployment job; starting/stopping services remains owner-run.
|
||||
- Rollback preflight validates digest-pinned image references against archived
|
||||
image IDs and local availability, then refuses before stopping services or
|
||||
creating a backup because the current flow cannot safely switch immutable
|
||||
digest references. This behavior is covered by synthetic local tests; it has
|
||||
not been exercised on the VPS.
|
||||
- This branch adds an offline release-manifest validator and tests for the
|
||||
repository allowlist intended for a future root-owned host integration. The
|
||||
manifest format does not prove source provenance. No registry, host policy,
|
||||
publish credential, or deployment integration is configured or installed.
|
||||
- A repository-scoped official `act_runner` `0.2.13` installation is
|
||||
configured in `infra/runner/`: rootless Docker under `akyldash-runner`, 15 GiB
|
||||
ext4 loop-backed image/workspace storage, capacity 1, per-job limits (2 CPU,
|
||||
@@ -102,7 +111,15 @@ protected file metadata remain unverified.
|
||||
with exit status 1 since 2026-10-14; its journal had no entries, so the cause
|
||||
is unknown. The latest verified main CI run 75 and smoke run 76 succeeded on
|
||||
head `0e276ae6`. The Actions API returned 403 during this recheck, so no newer
|
||||
run state was independently retrieved. On
|
||||
run state was independently retrieved. A read-only SSH recheck on 2026-10-21
|
||||
confirmed the active `akyldash-act-runner.service` runs as
|
||||
`akyldash-runner` with `ProtectHome=read-only`; `ReadWritePaths` includes the
|
||||
runner runtime/config/log paths and `/run/user/999`. Its `ExecStart` uses
|
||||
`/etc/akyldash-runner/config.yaml`. The live unit retains a writable carveout
|
||||
for `/run/user/999`; the installer keeps that existing carveout and changes
|
||||
`ProtectHome` from `yes` to `read-only` so the runtime path remains visible.
|
||||
No VPS unit was changed. Main commit
|
||||
`d2fb4b81` passed CI run 115 and smoke run 116. On
|
||||
head `3a89dc21`, CI run 63 failed at the host audit root guard and smoke run 64
|
||||
succeeded. On head `436806a8`, CI run 65 failed in the Caddy container action
|
||||
with `exec: "validate": executable file not found`; Kaniko was skipped and
|
||||
@@ -118,8 +135,9 @@ protected file metadata remain unverified.
|
||||
reached healthy status for OpenSearch, backend, and Caddy, and returned HTTP
|
||||
200 for `/review` through Caddy. Its containers, networks, and volumes were
|
||||
removed afterward. This does not verify VPS startup, DNS, or public TLS.
|
||||
- The app has no migration framework or SMTP setup. Current app versions:
|
||||
backend `0.11.3`, frontend `0.1.2`, Telegram bot `0.2.2` (the bot is not
|
||||
- The app has no migration framework or SMTP setup. Repository versions on this
|
||||
branch are:
|
||||
backend `0.13.0`, frontend `0.2.0`, Telegram bot `0.2.2` (the bot is not
|
||||
deployed on this VPS).
|
||||
|
||||
## Corpus readiness (2026-10-18)
|
||||
@@ -159,6 +177,53 @@ protected file metadata remain unverified.
|
||||
unknown. No corpus was transferred and no production service, Docker socket,
|
||||
runner policy, or VPS configuration was changed.
|
||||
|
||||
## Current MinJust API staging (2026-10-20)
|
||||
|
||||
- The public site's current API uses `POST /api/v1/GetDocuments` with page size
|
||||
12, `GET /api/v1/GetDocument?DocumentCode=...`, and
|
||||
`GET /api/v1/GetEdition?editionId=...&lang=ru|kg`. A bounded page-1 probe
|
||||
reported 210,639 filtered records; no full listing was enumerated for this
|
||||
recovery. Official alias `1-4` resolves to canonical document code `205064`.
|
||||
- Document code `6` has 201 official editions. The API's `lang=kg` response
|
||||
places Kyrgyz HTML in `contentRu` while returning `contentKg=null`; staging
|
||||
maps by requested language (`kg` to archive `ky`) and preserves the edition
|
||||
order from the document response. Normalization and search export now retain
|
||||
and use that order instead of sorting IDs.
|
||||
- Without `exact=true`, one probe for edition `920292` returned response field
|
||||
`id=920492` despite a different text length/hash. Two bounded `exact=true`
|
||||
probes returned matching IDs and names/dates from `GetDocument`: edition
|
||||
`920292` / `04.08.1998` (485,487 RU characters, SHA-256
|
||||
`2b51c2ff4e9b91cbe2a7ed052a5a9ed9150bda993fc15c120ac948580926b718`) and
|
||||
edition `920492` / `13.04.2017` (1,139,908 RU characters, SHA-256
|
||||
`e5f0fdbb47766e5a5dbb971183d765e4c1dcaf9c969a218a84206244ce9132e8`).
|
||||
Every edition request now uses `exact=true`, requires returned ID equality,
|
||||
and checks RU/KY names against `GetDocument` metadata.
|
||||
- `backend/ingestion/minjust_current.py` stages one document into a separate
|
||||
generation, at no more than one request per second, with a 16 MiB response
|
||||
limit, a 4 GiB text budget per document, contract-bound checkpoints, and
|
||||
atomic publication. Staging requires a private 0700 directory owned by the
|
||||
current user and rejects symlinks under checkpoints/document paths; legacy
|
||||
corpus exclusions are anchored to the repository root. The corpus verifier
|
||||
continues to validate unchanged normalized manifests from versions 1.0.0
|
||||
and 1.1.0. The earlier non-exact pass is quarantined at
|
||||
`/tmp/akyldash-current-code6.ZAMC37u4ex/qualification.json`: its 240 language
|
||||
responses (304,493,520 bytes) remain unverified and are not reusable. The
|
||||
retained work-tree SHA-256 is
|
||||
`1e874c1d7fab1e00e7e45ff1b16422ea474e22ee13c901ac9a0612f47aa5d24a`.
|
||||
- Exact-only code-6 staging is complete at
|
||||
`/tmp/akyldash-current-code6-exact.L4k3AvnOlP`: 201 editions and 402
|
||||
complete language checkpoints, 604,377,885 raw bytes. The raw manifest
|
||||
passes `quick_check`; its document checksum matches the staged directory at
|
||||
`81d8e1a53b2ebef61f08b56d04b3f0a80077d587a4fadae0152bb4b7f13055ea`.
|
||||
Separate normalization at
|
||||
`/tmp/akyldash-current-code6-exact.L4k3AvnOlP-normalized` contains 201
|
||||
editions in official response order, passes `quick_check`, and records
|
||||
normalizer `1.1.0` (2,048,797,024 bytes). The prior `data/minjust-cbd` and
|
||||
`data/minjust-normalized` trees and production were not used as input or
|
||||
modified. The raw staging manifest still records attachments and embedded
|
||||
media as unverified; this single document does not establish corpus
|
||||
completeness or production readiness.
|
||||
|
||||
## Checks not completed
|
||||
|
||||
- Docker Engine version from the server API, container and volume inventory,
|
||||
|
||||
@@ -75,6 +75,45 @@ repository defines sudo helpers for fixed verification, read-only Compose
|
||||
`config`/`status`, and guarded rollback, but none is installed on the VPS. They
|
||||
do not start or stop services.
|
||||
|
||||
## Release image contract (preparation only)
|
||||
|
||||
`infra/deploy/validate_release.py` checks an offline JSON release manifest
|
||||
against an explicitly supplied repository allowlist. Schema version 1 requires
|
||||
exactly `schema_version`, `source_ref`, `commit`, and `images`; the source ref
|
||||
must be `refs/heads/main`, the commit must be a 40-character lowercase Git
|
||||
SHA-1, and the images must contain exactly `backend` and `opensearch` as
|
||||
`repository@sha256:<64 lowercase hex>` references. The policy has schema
|
||||
version 1 and one non-empty repository allowlist for each service. Unknown or
|
||||
duplicate JSON keys are rejected.
|
||||
|
||||
The validator takes the manifest path and an explicit policy path:
|
||||
|
||||
```bash
|
||||
python3 infra/deploy/validate_release.py release.json --policy /etc/akyldash/release-policy.json
|
||||
```
|
||||
|
||||
The policy shape is `{"schema_version":1,"repositories":{"backend":["registry.invalid/akyldash/backend"],"opensearch":["registry.invalid/akyldash/opensearch"]}}`.
|
||||
These `registry.invalid` names are format examples only; this change does not
|
||||
create a policy file or approve a registry.
|
||||
|
||||
The repository name in the policy must be approved for the registry. No real
|
||||
registry namespace or root-owned host policy is configured by this change; do
|
||||
not use the synthetic `registry.invalid` names in tests as production values.
|
||||
The host integration must load policy only from its fixed root-owned path.
|
||||
This validator checks format and allowlist only: a manifest can claim a main
|
||||
ref and commit, but is not proof of source, build provenance, signature, or
|
||||
trusted publication. CI still has no publish credentials, image publication,
|
||||
host helper, or deploy trigger.
|
||||
|
||||
Compose merges an image override with the existing service `build` entries.
|
||||
The regression test renders fake digest references and confirms both entries
|
||||
remain; any future deploy command must use `docker compose up --no-build` so
|
||||
the host cannot silently build a different image. This does not make digest
|
||||
rollback available: the current rollback helper expects local tags. A separate
|
||||
host-side deploy/rollback design, owner-approved registry permissions and
|
||||
retention, and manual backup/health/rollback end-to-end validation are still
|
||||
required before publishing or enabling CD.
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/ci.yaml` runs the Python unit suites and builds both
|
||||
|
||||
@@ -18,11 +18,18 @@ sudo -n /usr/local/sbin/akyldash-rollback \
|
||||
The helper verifies archive integrity, Compose image references, deployment
|
||||
file hashes, and the availability of both prior local images. It makes and
|
||||
verifies a second cold backup, restores only the prior backend/OpenSearch image
|
||||
tags, waits for Compose healthchecks, and checks HTTPS locally. It refuses to
|
||||
restore if the current Compose/Caddyfile differs from the saved release. This
|
||||
does not restore data or automatically undo a release with an unknown schema
|
||||
change. The application has no formal migration framework; compatibility must
|
||||
be checked for every change. If it is uncertain, stop and ask the owner.
|
||||
tags, waits for Compose healthchecks, and checks HTTPS locally. Digest-pinned
|
||||
references are validated against their recorded local image IDs, but rollback
|
||||
fails before stopping services or making a backup when either image reference
|
||||
uses `@sha256:`. The helper cannot safely switch immutable digest references
|
||||
with the current release-recording flow. Do not retag a digest manually; digest
|
||||
rollback needs a separately reviewed atomic release-reference mechanism.
|
||||
|
||||
It refuses to restore if the current Compose/Caddyfile differs from the saved
|
||||
release. This does not restore data or automatically undo a release with an
|
||||
unknown schema change. The application has no formal migration framework;
|
||||
compatibility must be checked for every change. If it is uncertain, stop and ask
|
||||
the owner.
|
||||
|
||||
The initial owner-run deployment must record its running backend image ID in
|
||||
`/opt/akyldash/deployed-image.txt` with mode `0600`; the rollback helper updates
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
- Telegram-бот: `0.2.2`
|
||||
- Telegram-бот на Synology: `0.2.1`
|
||||
- Backend: `0.11.3`
|
||||
- Backend: `0.12.2`
|
||||
- Frontend: `0.1.2` — поиск, лаборатория релевантности, аккаунты
|
||||
|
||||
## Краткий итог
|
||||
@@ -282,4 +282,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
|
||||
@@ -0,0 +1,355 @@
|
||||
# План точного воспроизведения поискового интерфейса
|
||||
|
||||
Дата: 23 октября 2026 года.
|
||||
|
||||
Статус: основная реализация и локальная приёмка выполнены в
|
||||
`feature/frontend-pixel-perfect`; после первичного прогона скорректированы
|
||||
навигация, плотность результатов, заголовок документа, fixture-контент,
|
||||
клавиатурный trap для диалогов и компактные позиционируемые подсказки
|
||||
лаборатории. Скриншоты, покропленные сравнения и расширенный
|
||||
Playwright-сценарий находятся в репозитории; см. [отчёт](visual-review/README.md).
|
||||
Локальные тесты и guards из CI прошли. Ожидают runner: проверки Compose/Caddy и
|
||||
сборка production images. Независимый review запускает владелец. Скринридер и
|
||||
реальный корпус всё ещё требуют проверки.
|
||||
|
||||
## 1. Цель, границы и источники истины
|
||||
|
||||
Привести существующую авторизованную поисковую страницу `/` к композиции
|
||||
утверждённого изображения «Акылдаш · Түндүк», сохранив реальные API,
|
||||
авторизацию, безопасность и отдельный сценарий лаборатории `/review`.
|
||||
|
||||
Источники:
|
||||
|
||||
- [DESIGN.md](../../DESIGN.md) — единственная нормативная дизайн-система:
|
||||
токены, типографика, доступность, постоянные правила компонентов.
|
||||
- [Исходный концепт](../design/tunduk-desktop-concept.png) — визуальный
|
||||
эталон композиции. Вложение пользователя 1280×800 визуально соответствует
|
||||
этому сохранённому изображению более высокого разрешения.
|
||||
- [План frontend СПС](frontend-search-sps-plan.md) — продуктовый контекст.
|
||||
- [Readiness plan](frontend-design-readiness-plan.md) — отдельные условия
|
||||
готовности публичного продукта, которые этот план не отменяет.
|
||||
|
||||
Изображение задаёт композицию и пропорции; `DESIGN.md` задаёт токены,
|
||||
шрифты и ограничения. До реализации фиксировать расхождения, а не молча
|
||||
изменять палитру, шрифты или доступность ради совпадения растровых пикселей.
|
||||
Временные задачи и результаты измерений остаются здесь; новые постоянные
|
||||
правила добавляются в `DESIGN.md` без копирования всей дизайн-системы.
|
||||
|
||||
Не входят: публичный запуск, изменение прав доступа, ранжирования, корпуса,
|
||||
индекса, production, регистрация, аналитика, сравнение редакций, переход
|
||||
на новый frontend framework. Содержимое демонстрационного документа
|
||||
не становится юридическими данными продукта.
|
||||
|
||||
Figma не является обязательной зависимостью: сохранённого эталона достаточно.
|
||||
При необходимости совместного редактирования можно подготовить отдельный
|
||||
Figma-макет с Auto Layout и переменными, ссылающийся на эти же правила;
|
||||
это не заменяет проверку фактической вёрстки в браузере.
|
||||
|
||||
## 2. Исходное состояние до реализации
|
||||
|
||||
Таблица ниже фиксирует базовую проверку до реализации, выполненную по коду и
|
||||
изображениям без браузера; строки не описывают текущее состояние. Последующие
|
||||
результаты Playwright сохранены в [отчёте визуальной приёмки](visual-review/README.md).
|
||||
Они относятся к локальному UI со стабильными fixture-ответами и не подтверждают
|
||||
состояние production или актуального OpenSearch.
|
||||
|
||||
Основные файлы:
|
||||
|
||||
- `backend/search/review.html` — разметка, встроенные CSS и JavaScript обоих
|
||||
сценариев, footer и авторизация.
|
||||
- `backend/search/api.py` — выдача страницы для `/` и `/review`, API поиска,
|
||||
фильтров и редакций.
|
||||
- `backend/test_search_api.py` — существующие проверки API.
|
||||
- `.gitea/workflows/ci.yaml` — обязательные проверки и сборка образов.
|
||||
|
||||
| Проблема | Подтверждение в реализации | Требуемый результат |
|
||||
|---|---|---|
|
||||
| Другая шапка | Общая `.review__header`, поиск отдельной формой ниже | Бренд, поиск и язык в одной верхней строке |
|
||||
| Нет второй строки эталона | Нет общей полосы навигации и активных фильтров | Навигация и удаляемые фильтры под шапкой |
|
||||
| Другие пропорции | `.search__layout`: `230px / .9fr / 1.2fr`, `min-height: 65vh` | Измеренная сетка и корректная высота рабочей области |
|
||||
| Не тот вид фильтров | Все справочники выводятся radio-списками, даты вертикально | Компактные группы, select органа, даты рядом |
|
||||
| Выбранный результат неполон | Есть только фоновое выделение | Фон, рамка, подпись «Открыт», точные отступы |
|
||||
| Неполная панель документа | Есть заголовок, select редакции/языка, метаданные и текст | Полная иерархия и работающие действия эталона |
|
||||
| Смешаны стили документа | Объявлен `.search__document-body`, статья использует `.review__document-body` | Одна корректная область стилей поискового документа |
|
||||
| Шрифты не поставляются | Noto указаны в CSS, локальные файлы не найдены | Проверенная загрузка локальных шрифтов |
|
||||
| Нет фирменных ресурсов | Знак сделан CSS-кружком; орнамент и favicon отсутствуют | SVG-знак, орнамент, favicon |
|
||||
| Лабораторная панель на поиске | `.review__actions` вне скрываемого `<main>`; для `/` не скрывается | Оценивание только на `/review` |
|
||||
| Счётчик вводит в заблуждение | Выводится `payload.results.length`; API не возвращает total | Явное число на странице либо отдельно реализованный total |
|
||||
| Нет визуального контроля | Найденные тесты проверяют API, не геометрию страницы | Воспроизводимые браузерные снимки и сравнение |
|
||||
|
||||
Это исходное описание состояния до реализации. Сейчас переключатель RU/KY,
|
||||
отдельный язык поиска, реальные редакции и язык документа, оглавление, поиск
|
||||
по тексту документа, разворачивание, печать и отдельный маршрут `/review`
|
||||
реализованы. Подробности текущей локальной приёмки и её ограничения приведены
|
||||
в [отчёте](visual-review/README.md); оставшиеся непроверенные состояния не
|
||||
считаются принятыми только потому, что чекбоксы плана перечислены ниже.
|
||||
|
||||
## 3. Этап 0 — воспроизводимый эталон
|
||||
|
||||
- [x] Перед реализацией перечитать актуальные инструкции, `DESIGN.md`,
|
||||
реализацию маршрутов и обязательные проверки; использовать установленный
|
||||
Ponytail согласно правилам репозитория.
|
||||
- [x] Сохранить чужие незакоммиченные изменения, создать тематическую
|
||||
`feature/` ветку и не переносить посторонние изменения в PR.
|
||||
- [x] Зафиксировать основной viewport 1280×800 CSS px, браузер и его версию,
|
||||
ОС, масштаб 100%, device scale factor и остальные viewport в отчёте.
|
||||
- [ ] Получить исходный снимок текущего интерфейса на тех же размерах.
|
||||
- [x] Подготовить детерминированные тестовые данные: запрос «налог на прибыль»,
|
||||
три результата из изображения, выбран первый, открыта соответствующая
|
||||
демонстрационная редакция, фильтр статуса и исходная прокрутка.
|
||||
- [x] Изолировать fixture от реального режима и пометить демонстрационное
|
||||
содержимое только в тестовом сценарии. Не добавлять подмену юридических данных
|
||||
в production-путь.
|
||||
- [x] Нормализовать сохранённое изображение до 1280×800 без изменения
|
||||
пропорций и сохранить параметры подготовки эталона.
|
||||
- [x] Записать конфликты изображения с нормативной типографикой и токенами;
|
||||
согласованные ограничения приведены в отчёте.
|
||||
|
||||
Критерий завершения: один и тот же тестовый сценарий воспроизводится
|
||||
независимо от состояния OpenSearch и текущей реальной выдачи; реальный режим
|
||||
продолжает использовать API. Авторизация не ослабляется ради снимков.
|
||||
|
||||
## 4. Этап 1 — карта геометрии
|
||||
|
||||
Эталон 1586×992 пропорционально вписан в 1280×800; фактические значения
|
||||
сверены с Playwright при device scale factor 1. Допуск границ крупных зон —
|
||||
около 1 CSS px.
|
||||
|
||||
| Область | Нормализованный эталон | Реализация |
|
||||
|---|---:|---:|
|
||||
| Шапка и строка навигации | y ≈ 0–126 px | y = 0–127 px |
|
||||
| Рабочая область | y ≈ 126–744 px | y = 127–744 px |
|
||||
| Footer | y ≈ 744–800 px | y = 744–800 px |
|
||||
| Фильтры | x ≈ 0–226 px | x = 0–226 px |
|
||||
| Результаты | x ≈ 226–648 px | x = 226–648 px |
|
||||
| Документ | x ≈ 648–1280 px | x = 648–1280 px |
|
||||
|
||||
Внутренние контрольные координаты и ограничения см. в
|
||||
[отчёте визуальной приёмки](visual-review/README.md); там же есть overlay и
|
||||
разница по каждой крупной области.
|
||||
|
||||
- [x] Измерить крупные границы, основную навигацию и ключевые элементы
|
||||
документа по исходнику; фактические значения записаны в отчёте.
|
||||
- [ ] Измерить все внутренние отступы, размеры полей и кнопок, межстрочные
|
||||
интервалы и разделители по всем областям.
|
||||
- [x] Зафиксировать адаптивные точки и проверить сетку на 1440/1024/390/320 px;
|
||||
реальные длинные данные ещё не проверены.
|
||||
- [ ] Разделить метрики повторяемых элементов и высоты, зависящие от текста.
|
||||
Не выравнивать принудительно высоту всех результатов или разделов.
|
||||
- [ ] Определить владельца прокрутки каждой области, поведение footer
|
||||
при коротком содержимом и доступность длинного документа.
|
||||
|
||||
Критерий завершения: геометрия описана измерениями и адаптивными правилами,
|
||||
а не независимыми приблизительными настройками каждого блока.
|
||||
|
||||
## 5. Этап 2 — разделение сценариев и базовых стилей
|
||||
|
||||
- [x] Разделить композиции поиска и лаборатории, сохранить маршруты,
|
||||
авторизацию, кабинет, управление пользователями, обучение и черновики.
|
||||
- [x] Ограничить лабораторные элементы маршрутом `/review`, включая
|
||||
панель проверяющего и сохранения; маршрут проверен браузерным тестом.
|
||||
- [ ] Использовать общий слой семантических токенов и базовых элементов;
|
||||
стили поискового экрана изолировать BEM-классами.
|
||||
- [ ] Устранить дублирующие и неиспользуемые правила, исправить класс статьи.
|
||||
- [ ] Убрать зависимость шапки и footer поиска от лабораторного layout.
|
||||
- [ ] Если CSS/JS/ресурсы выносятся из HTML, добавить ограниченную выдачу
|
||||
static-файлов с MIME, защитой от обхода путей и сохранением защитных
|
||||
заголовков. Убедиться, что ресурсы входят в production-образ.
|
||||
|
||||
Ожидаемые зоны изменений: `backend/search/review.html`, при необходимости
|
||||
каталог static-ресурсов рядом с ним и `backend/search/api.py`.
|
||||
Не вводить Next.js, React или новый сборщик только ради этой задачи.
|
||||
|
||||
Критерий завершения: оба сценария работают независимо, общие CSS не меняют
|
||||
их композицию случайным образом; существующие права доступа сохранены.
|
||||
|
||||
## 6. Этап 3 — шрифты, знак, орнамент и favicon
|
||||
|
||||
- [ ] Поставить локальные WOFF2 Noto Sans 400/500/600 и Noto Serif 600/700
|
||||
с лицензиями; исключить случайное синтетическое начертание.
|
||||
- [x] Проверить загрузку, `font-display`, кириллицу и `Ө ө Ү ү Ң ң`.
|
||||
Визуальные снимки выполнять после `document.fonts.ready`.
|
||||
- [ ] Подготовить SVG-знак по визуальному направлению референса;
|
||||
сверить размер, толщину линий и положение относительно названия.
|
||||
- [ ] Подготовить отдельный симметричный золотистый SVG-орнамент,
|
||||
`aria-hidden`, без перекрытия управления и текста.
|
||||
- [x] Подготовить favicon SVG и растровый fallback 32×32; подключить
|
||||
в `<head>`, проверить выдачу и MIME. Масштабирование SVG проверено визуально.
|
||||
- [ ] Подготовить единые SVG-иконки поиска, очистки, календаря, печати,
|
||||
оглавления, разворачивания и стрелок; иконкам-кнопкам дать доступные имена.
|
||||
- [ ] Перенести значения в токены, не размножать HEX по компонентам.
|
||||
|
||||
Критерий завершения: шрифты реально загружены, знак и орнамент являются
|
||||
ресурсами, favicon отображается; нет CSS-заглушек вместо фирменной графики.
|
||||
|
||||
## 7. Этап 4 — сборка композиции
|
||||
|
||||
Работать от крупных областей к деталям; сначала геометрия, затем типографика.
|
||||
|
||||
- [ ] Шапка: знак, название, разделитель, подпись продукта, поле с подписью
|
||||
и очисткой, «Найти», язык интерфейса. Служебные действия доступны компактно.
|
||||
- [ ] Вторая строка: «Поиск», «Об источниках», удаляемые фильтры;
|
||||
демонстрационная подпись только для fixture. «Об источниках» открывает
|
||||
реальные сведения о происхождении данных, а не пустую заглушку.
|
||||
- [ ] Три панели: непрерывные границы, правильные фоны и пропорции,
|
||||
отсутствие лишнего свободного ряда статуса в стабильном состоянии.
|
||||
Сообщения для скринридера сохраняются.
|
||||
- [ ] Фильтры: заголовок, текстовый сброс, статус, вид, орган, даты рядом,
|
||||
орнамент в свободной нижней части без перекрытия длинного содержимого.
|
||||
- [ ] Результаты: заголовок и честный счётчик, компактная сортировка,
|
||||
строки выдачи, выбранная строка с рамкой/фоном/«Открыт», метаданные,
|
||||
безопасная подсветка выдержки, пагинация.
|
||||
- [ ] Документ: breadcrumbs и разворачивание, отдельная роль заголовка,
|
||||
компактные метаданные, язык, действия, поиск внутри, поле чтения.
|
||||
- [ ] Footer: бренд слева, фактические версии frontend/backend справа;
|
||||
не копировать устаревшую backend-версию и слово «макет» из изображения.
|
||||
|
||||
Критерий завершения: порядок, вложенность и пропорции всех крупных зон
|
||||
соответствуют эталону; нет лишних лабораторных блоков.
|
||||
|
||||
## 8. Этап 5 — рабочее поведение и интеграция
|
||||
|
||||
- [ ] Сохранить поиск, сортировку, пагинацию, загрузку редакций и языков.
|
||||
- [x] Синхронизировать фильтры с URL и удалять отдельный фильтр без сброса
|
||||
остальных; сохранение второго фильтра проверяется Playwright.
|
||||
- [ ] Проверить восстановление и историю навигации через прямые ссылки и Back/Forward.
|
||||
- [ ] Согласовать вид фильтров с контрактом: текущий API принимает одиночные
|
||||
значения. Не имитировать множественный выбор checkbox без поддержки API.
|
||||
Если нужен multiselect, отдельно описать и проверить расширение контракта.
|
||||
- [ ] Разделить язык интерфейса, поиска и текста документа. Для RU/KY
|
||||
перевести подписи, статусы и ошибки; смена UI не подменяет текст редакции.
|
||||
- [ ] Не выдавать число на странице за общее: использовать явную подпись
|
||||
либо отдельно реализовать достоверный total с проверкой его семантики.
|
||||
- [ ] Построить оглавление по реальным заголовкам, с корректными якорями;
|
||||
при отсутствии структуры показать понятное состояние.
|
||||
- [ ] Реализовать поиск по текстовым узлам документа: ввод/очистка,
|
||||
подсветка, активное совпадение, счётчик, предыдущий/следующий,
|
||||
прокрутка и состояние нулевого результата. Не повреждать HTML,
|
||||
ссылки и таблицы; не вставлять пользовательский запрос через `innerHTML`.
|
||||
- [ ] Реализовать разворачивание/возврат с сохранением выбранной редакции,
|
||||
прокрутки и корректного фокуса.
|
||||
- [x] Добавить печать через print CSS: документ, его реквизиты и источник
|
||||
без поисковых и служебных панелей; служебные элементы проверены в print media.
|
||||
- [ ] Защитить смену запроса/документа/редакции от запоздавших ответов
|
||||
через отмену запроса или проверку актуальности результата.
|
||||
- [ ] Сохранить безопасный вывод очищенного документного HTML и ссылок,
|
||||
обработку ошибок и ограничения авторизации.
|
||||
|
||||
Критерий завершения: каждый видимый элемент эталона выполняет действие;
|
||||
нет декоративных кнопок, фиктивных языков и выдуманных юридических статусов.
|
||||
|
||||
## 9. Этап 6 — адаптивность, типографика и состояния
|
||||
|
||||
- [ ] Настроить размеры, веса, line-height и отступы после фиксации сетки.
|
||||
Сверить переносы названия документа, результатов и плотность абзацев.
|
||||
- [x] Проверить 1280×800 и ширины 1440/1024/390 px; дополнительно 320 px.
|
||||
Высоты и параметры окружения записаны в отчёте.
|
||||
- [ ] Проверить реальные длинные редакции, длинные названия на RU/KY и подобрать
|
||||
breakpoints на реальных данных корпуса.
|
||||
- [x] На узких экранах открывать фильтры отдельной доступной панелью,
|
||||
документ — отдельным представлением с возвратом к результатам; проверено на 390 px.
|
||||
- [x] Сокращать/убирать орнамент на телефоне; сохранять текст без
|
||||
горизонтальной прокрутки, широкие таблицы — в собственной области прокрутки.
|
||||
Проверено синтетической широкой таблицей; реальные таблицы корпуса остаются открыты.
|
||||
- [x] Проверить длинные названия, отсутствие редакций, широкую таблицу и
|
||||
широкое изображение синтетическими данными.
|
||||
- [ ] Проверить одноязычные реальные редакции, ссылки, много фильтров и
|
||||
длинный контент из реального корпуса.
|
||||
- [x] Проверить загрузку редакции, empty, search/edition error, retry,
|
||||
disabled-состояние без редакции и focus-visible; query сохраняется при ошибке.
|
||||
- [ ] Проверить hover и сценарии ошибок авторизации/администрирования.
|
||||
- [x] Проверить Tab/Shift+Tab/Escape, возврат фокуса мобильной панели и
|
||||
диалогов, а также `prefers-reduced-motion`.
|
||||
- [ ] Проверить доступные имена всех элементов, live-сообщения, контраст,
|
||||
масштабирование, скринридер и touch-области на поддерживаемых устройствах.
|
||||
|
||||
Критерий завершения: точность desktop не приводит к переполнению,
|
||||
недоступности действий или потере контекста на узком экране.
|
||||
|
||||
## 10. Этап 7 — визуальная приёмка
|
||||
|
||||
Выполнить один пакет первичных снимков всех контрольных размеров,
|
||||
собрать дефекты по областям, исправить одним пакетом и выполнить один
|
||||
подтверждающий проход. Новые циклы обосновывать конкретными оставшимися
|
||||
дефектами или замечаниями независимого reviewer, не бесконечной полировкой.
|
||||
|
||||
- [x] Дождаться шрифтов и стабильных fixture-данных, задать исходную
|
||||
прокрутку; снимки делаются после `document.fonts.ready`.
|
||||
- [x] Проверить, что снимки показывают загруженный поисковый сценарий, а не
|
||||
login, ошибку или другой маршрут.
|
||||
- [x] Для 1280×800 подготовить overlay 50%, difference image и отдельные
|
||||
сравнения шапки, фильтров, результатов, документа и footer.
|
||||
- [x] Сохранить снимки остальных размеров и отчёт с условиями, отклонениями,
|
||||
исправлениями и известными ограничениями.
|
||||
- [ ] Передать независимому reviewer запрос пользователя, эталон,
|
||||
`DESIGN.md`, этот план, diff и снимки всех проверенных размеров.
|
||||
|
||||
Критерии для детерминированного fixture при 1280×800:
|
||||
|
||||
| Проверка | Условие приёмки |
|
||||
|---|---|
|
||||
| Границы крупных зон | Отклонение от зафиксированной карты до 2 CSS px |
|
||||
| Отступы и размеры управления | Отклонение до 2 CSS px от измерений |
|
||||
| Ключевые заголовки | Совпадают переносы при согласованных шрифтах; исключения объяснены |
|
||||
| Композиция | Все зоны и элементы эталона присутствуют, порядок совпадает |
|
||||
| Ресурсы | Загружены шрифты, знак, орнамент, favicon, единые иконки |
|
||||
| Рабочий сценарий | Поиск, фильтры, редакции, действия и навигация работают |
|
||||
| Адаптивность | Нет перекрытий, обрезанного управления и общей горизонтальной прокрутки |
|
||||
| Изоляция | Нет лабораторного оценивания на поиске, `/review` без регрессий |
|
||||
|
||||
Нулевая попиксельная разница не является критерием: сглаживание текста,
|
||||
растровая природа концепта и нормативные токены дают объяснимые различия.
|
||||
Один процент сходства всего экрана не заменяет проверки каждой области.
|
||||
Достижение допусков подтверждается измерениями, а не общей оценкой «похоже».
|
||||
|
||||
## 11. Этап 8 — проверки, версия и PR
|
||||
|
||||
Этот этап относится к реализации в feature-ветке. Frontend повышен до `0.2.3`,
|
||||
backend — до `0.13.0` для allowlist-выдачи локальных шрифтов. Контракт Search
|
||||
API v1 не менялся.
|
||||
|
||||
- [x] Выполнить доступные локально проверки из `AGENTS.md` и CI: backend,
|
||||
Telegram bot, backup, deploy, host/root/verifier/audit/Compose/production
|
||||
config/runner guards, shell syntax и Playwright.
|
||||
- [ ] Получить успешный CI на назначенном runner: `docker compose config`,
|
||||
проверка Caddyfile и сборка backend/OpenSearch production images.
|
||||
- [x] Добавить проверки фактических изменений: static/favicon, браузерные
|
||||
сценарии поиска/редакций/поиска внутри/навигации и регрессии лаборатории;
|
||||
визуальное сравнение использует стабильный fixture.
|
||||
- [x] Повысить версии для совместимого функционального изменения;
|
||||
frontend/backend версии согласованы в footer.
|
||||
- [x] Выполнить push и найти существующий PR ветки через `gitea-api`;
|
||||
изменения остаются в одном PR #53.
|
||||
- [ ] После каждого push независимый reviewer проверяет полный текущий PR,
|
||||
прежние исправления и оставляет краткий PR-комментарий с явным выводом
|
||||
о наличии или отсутствии блокирующих замечаний; reviewer не редактирует код.
|
||||
- [ ] Исправить обоснованные замечания в той же ветке/PR, повторить проверки,
|
||||
commit/push и независимый review.
|
||||
- [ ] После успешных проверок и устранения блокирующих замечаний обновить
|
||||
существующий changelog, записав значимые изменения для пользователя.
|
||||
- [ ] Commit/push changelog в тот же PR, повторить обязательные проверки
|
||||
и независимый review окончательного состояния.
|
||||
|
||||
Готовность к merge: проверки проходят, финальный review не содержит
|
||||
блокирующих замечаний, changelog и версии согласованы, визуальные доказательства
|
||||
доступны reviewer. Не сливать автоматически и не развёртывать production.
|
||||
|
||||
## 12. Порядок выполнения и итоговые материалы
|
||||
|
||||
Зависимости: эталон → измерения → разделение сценариев → ресурсы → композиция
|
||||
→ поведение → адаптивность и состояния → визуальная приёмка → финальный PR.
|
||||
Не начинать микроподгонку кнопок до исправления общей геометрии.
|
||||
|
||||
Ожидаемые материалы реализации:
|
||||
|
||||
- обновлённая поисковая страница и сохранённый сценарий лаборатории;
|
||||
- локальные шрифты с лицензиями, SVG-знак, орнамент, favicon и иконки;
|
||||
- детерминированный тестовый сценарий и воспроизводимый способ снимков;
|
||||
- карта измерений, снимки, overlay/diff и отчёт приёмки;
|
||||
- результаты обязательных проверок и независимого review;
|
||||
- версии, changelog и один PR без автоматического merge.
|
||||
|
||||
Чекбоксы отмечать только после выполнения и проверки. Локальные проверки,
|
||||
которые реально выполнены, зафиксированы в отчёте visual-review.
|
||||
Этот документ не подтверждает готовность API, публичного продукта или
|
||||
production.
|
||||
@@ -262,4 +262,4 @@ runtime-зависимостями frontend. Регистрация в стор
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
|
||||
@@ -0,0 +1,334 @@
|
||||
# Единый план корректировки интерфейса «Акылдаш»
|
||||
|
||||
Дата аудита: **23 октября 2026 года**.
|
||||
Базовые пункты UI-01—UI-33 исправлены и вошли в `main` через PR #54.
|
||||
На 24 октября 2026 года отдельный follow-up по замечаниям владельца для поиска,
|
||||
личного кабинета и лаборатории реализован; локальный браузерный набор пройден.
|
||||
Ожидаются CI и ручное PR review для pushed SHA.
|
||||
|
||||
Этот файл заменяет `frontend-pixel-perfect-plan.md` и является единственным
|
||||
рабочим планом исправления UI/UX поиска, лаборатории и служебных окон.
|
||||
Продуктовый roadmap и требования к юридической готовности остаются отдельными
|
||||
документами: они не являются конкурирующими планами исправления интерфейса.
|
||||
|
||||
## 1. Источники истины и границы
|
||||
|
||||
- [DESIGN.md](../../DESIGN.md) — нормативная система «Түндүк».
|
||||
- [Утверждённая композиция](../design/tunduk-desktop-concept.png) — визуальный
|
||||
ориентир, а не образец юридических данных или текущих функций.
|
||||
- [Целевые редактируемые макеты Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB)
|
||||
— предлагаемая компоновка поиска, лаборатории, кабинета и адаптивных видов.
|
||||
- Этот файл — дефекты, требования к поведению, порядок исправления и приёмка.
|
||||
|
||||
Проверялся работающий `https://akyldash.kg/` в авторизованной сессии,
|
||||
версии в интерфейсе: Frontend **0.2.3**, Backend **0.13.0**.
|
||||
Локальный checkout содержит более ранние версии; код и live-сайт нельзя
|
||||
считать одинаковыми без сверки SHA перед реализацией. Инфраструктурные
|
||||
документы не заменяют результаты этой браузерной проверки.
|
||||
|
||||
В этой задаче исправляются frontend и доступ к статическому экрану `/users`.
|
||||
Backend-проверки ролей и операций управления пользователями не меняются.
|
||||
Production, пользователи, пароли, сохранённые оценки, ранжирование и правовой
|
||||
корпус не меняются. Реальные учётные записи не используются в регрессионном
|
||||
fixture.
|
||||
|
||||
## 2. Что проверено
|
||||
|
||||
Проверка включала клики и чтение DOM, снимки и измерение переполнения.
|
||||
Контрольные viewport: **1440×900, 1280×800, 1024×768, 390×844**;
|
||||
дополнительно исходный viewport браузера 1064×1244. Изображения пользователя
|
||||
с более широким desktop учитываются как дополнительные свидетельства,
|
||||
но не выдаются за собственный браузерный прогон.
|
||||
|
||||
| Поверхность / сценарий | Выполнено | Ограничение |
|
||||
|---|---|---|
|
||||
| Начальный `/` | Пустой запрос, доступные действия, фильтры, footer | Ошибочное empty-состояние подтверждено |
|
||||
| Поиск RU | Запрос «налог на прибыль», загрузка выдачи, открытие кодекса | Не экспертная проверка юридической релевантности |
|
||||
| Фильтры | Выбор статуса, появление chip, снятие фильтра | Все комбинации справочников не перебирались |
|
||||
| Сортировка / страницы | Выбор даты / релевантности, переход на страницу 2, сброс на страницу 1 | Оценивалось состояние UI и URL |
|
||||
| Пустая выдача | Запрос `zzzzqwerty987654321` | Серверный сбой не провоцировался |
|
||||
| Документ | Реальный текст, список редакций, переключение редакции и языка, разворот | Печать на принтер / в PDF не запускалась |
|
||||
| Поиск в документе | Ввод фразы, подсветка, кнопка следующего совпадения | Поиск после смены редакции требует отдельного теста |
|
||||
| RU/KY интерфейс | Переключение и чтение подписей | Найдены непереведённые подписи; полнота всех строк не подтверждена |
|
||||
| Мобильные фильтры | Открытие и закрытие панели | Полный touch-прогон на физическом устройстве не выполнен |
|
||||
| «Об источниках» | Открытие, чтение, закрытие | Внешний сайт Минюста не является частью этого аудита |
|
||||
| Личный кабинет | Открытие, профиль, поля смены пароля, Escape и возврат фокуса | Пароли не вводились и не менялись |
|
||||
| Пользователи | Открытие из кабинета и из лаборатории, чтение структуры | Создание, удаление и сброс пароля не выполнялись |
|
||||
| `/review` | Пустое состояние, поиск, шкала оценок, открытие документа, обучение | Оценки не отправлялись; серверное сохранение не подтверждено |
|
||||
| Авторизация / истёкшая сессия | Установлено наличие в локальной реализации | Live login и принудительная смена пароля не проверялись: действующую сессию не завершали |
|
||||
|
||||
Снимки: [каталог аудита](../design/audit-2026-10-23/).
|
||||
Участки с реальными списками пользователей не переносятся в макеты Figma;
|
||||
там используются явно условные примеры. Часть снимков лаборатории сделана
|
||||
с длинной страницей; снимок не гарантирует корректность всех скрытых участков.
|
||||
|
||||
## 3. Реестр недостатков
|
||||
|
||||
**P1** — исправить до визуальной приёмки / выпуска; **P2** — существенное
|
||||
улучшение понятности и последовательности. Предложения обозначены отдельно
|
||||
от подтверждённых дефектов. Отсутствие функции само по себе не означает баг.
|
||||
|
||||
| ID | Приоритет | Подтверждённый недостаток / свидетельство | Корректировка и условие приёмки |
|
||||
|---|---|---|---|
|
||||
| UI-01 | P1 | В начальном поиске одновременно «Введите запрос» и «Документы не найдены» | Разделить initial/loading/results/empty/error; empty только после завершённого поиска |
|
||||
| UI-02 | P1 | До выбора документа доступны печать/разворот/содержание, видны пустые селекты | До выбора — только понятное приглашение открыть результат; инструменты появляются после загрузки |
|
||||
| UI-03 | P1 | У фильтров горизонтальный и вертикальный скроллы; при 1064 px `clientWidth=194`, `scrollWidth=231` | Горизонтальное переполнение исключено; компактные группы, достаточная ширина, длинные списки раскрываются по запросу |
|
||||
| UI-04 | P1 | «Сбросить» обрезается заголовком панели | Заголовок и действие полностью видны на всех поддерживаемых размерах |
|
||||
| UI-05 | P1 | Счётчики отрываются от названий, частые переносы превращают фильтры в длинную колонку | Отдельные колонки выбора/подписи/числа; числа с разделителями; перенос только длинного названия по словам |
|
||||
| UI-06 | P1 | Все 28 видов и 8 статусов развёрнуты; орган и даты далеко ниже первого экрана | Основные варианты + «Все виды»/«Все статусы», сворачиваемые группы; сохранять доступ ко всем значениям |
|
||||
| UI-07 | P1 | Язык поиска имеет ширину около 78 px; «Русский»/«Кыргызский» обрезаются | Размер по самой длинной локализованной подписи и стрелке; проверка RU/KY без ellipsis |
|
||||
| UI-08 | P1 | На 390 px строка навигации горизонтально прокручивается, кабинет и фильтры уходят за край | Адаптивная перестройка: видимые поиск, фильтры, кабинет; ни одна основная кнопка не требует горизонтального скролла |
|
||||
| UI-09 | P2 | Кабинет — малозаметная иконка в правом конце второй строки; слева большой неинформативный промежуток | На desktop подписанная кнопка «Кабинет» в шапке; на mobile узнаваемая иконка с доступным именем и достаточной областью |
|
||||
| UI-10 | P1 | На 1024/1064 три колонки остаются тесными; название кодекса занимает большую часть панели | Адаптивный двухпанельный режим, скрытие фильтров, ограничения ширин; не сжимать все три колонки бесконечно |
|
||||
| UI-11 | P2 | Длинное название документа повторено в breadcrumb, заголовке и исходном тексте | Breadcrumb обозначает контекст; в заголовке короткое название при наличии отдельного поля, реквизиты ниже; официальный текст не переписывать |
|
||||
| UI-12 | P1 | Source link и toolbar переполняются на промежуточных ширинах; при 1064 px toolbar `412/427` | Действия переносятся группами, без обрезки; размер по содержимому, подписи редакции и языка постоянные |
|
||||
| UI-13 | P2 | В исходном тексте большие пустые абзацы и синие ссылки конфликтуют с оболочкой | Нормализовать отображение пустых блоков без потери структуры; безопасно унифицировать ссылки и отступы в scope документа |
|
||||
| UI-14 | P1 | В тексте есть широкие таблицы: например, `clientWidth=412`, `scrollWidth=834` | Таблицы — самостоятельная обозначенная область прокрутки; документ и весь экран не получают горизонтальный скролл |
|
||||
| UI-15 | P1 | В DOM документа обнаружены относительные ссылки на старые локальные Temp-пути и HTTP URL исходного редактора | Не показывать их как рабочие внутренние переходы; безопасно разрешать/классифицировать ссылки, не удаляя юридический текст; локальные/IP-ссылки не открывались |
|
||||
| UI-16 | P2 | Разворот открывает ещё одно окно с текстом; поиск, выбранное совпадение и toolbar не представлены в нём | Целевой режим чтения сохраняет редакцию, язык, положение и средства навигации; возврат очевиден |
|
||||
| UI-17 | P2 | Часть KY-интерфейса остаётся русской: например заголовки групп и служебные доступные подписи | Инвентаризировать все UI-строки; контент документа переводом интерфейса не подменять |
|
||||
| UI-18 | P1 | На `/review` два видимых footer; общий footer находится до сохранения | Ровно один видимый footer после основного контента и панели сохранения |
|
||||
| UI-19 | P1 | В лаборатории шапка, формы, панели и footer имеют разные границы и ширины | Общая оболочка и единые горизонтальные направляющие, совпадающие поля и ширины контейнеров |
|
||||
| UI-20 | P1 | Шапка лаборатории слишком высокая; на mobile рабочие результаты вытеснены далеко вниз | Компактный заголовок и помощь рядом; поиск и начало работы доступны без большого вступительного блока |
|
||||
| UI-21 | P1 | У кабинета фактически пустой `account-heading`, нет видимого заголовка | «Личный кабинет» в заголовке, корректное имя диалога для скринридера |
|
||||
| UI-22 | P1 | В кабинете нет крестика; «Закрыть» только внизу формы смены пароля | Единый крестик 44×44 справа сверху, доступное имя, Escape, возврат фокуса; нижняя отмена при редактировании |
|
||||
| UI-23 | P1 | Навигация, выход, профиль и смена пароля смешаны в одном неструктурированном окне | Отдельные разделы «Профиль» / «Безопасность»; служебные действия и выход отделены от формы |
|
||||
| UI-24 | P1 | «Пользователи» из кабинета открывает `users-dialog`, когда `account-dialog` остаётся открытым поверх него | Не оставлять конкурирующие модальные окна: закрыть кабинет с сохранением контекста и открыть целевой экран/диалог; фокус соответствует видимому окну |
|
||||
| UI-25 | P1 | Управление пользователями — длинный модальный список, закрытие ниже видимой области | Предпочтительно отдельный экран с таблицей; создать/сбросить — короткие отдельные диалоги с фиксированным заголовком и крестиком |
|
||||
| UI-26 | P2 | Удаление, сброс пароля, создание и данные входа перемешаны без ясной иерархии | Действия на строке пользователя; удаление отдельно и с подтверждением; не раскрывать все формы сброса постоянно |
|
||||
| UI-27 | P2 | Различаются оформление бренда/навигации/шрифтов, есть стандартные синие ссылки в лаборатории и кабинете | Единые header, logo, button, link, dialog и footer на всех поверхностях |
|
||||
| UI-28 | P2 | Диалоги закрываются разными кнопками в разных местах | Одна модель окна и доступного закрытия, описанная ниже |
|
||||
| UI-29 | P2 | Обучение автоматически появляется с задержкой и перехватывает фокус уже начатого ввода | Не перехватывать ввод; запуск по «Как это работает» либо ненавязчивому предложению до начала работы |
|
||||
| UI-30 | P2 | Шкала 0–3 на длинной выдаче создаёт плотный набор переносов; комментарии всегда занимают место | Полные подписи без обрезки, допускается сетка 2×2; комментарий раскрывается, введённый текст не теряется |
|
||||
| UI-31 | P2 | На пустом лабораторном экране «Сохранить оценку» выглядит готовой к отправке | Состояние зависит от наличия снимка; явное объяснение, прогресс и статус черновика; не менять смысл отсутствующей оценки |
|
||||
| UI-32 | P2 | В footer работающего сайта остаётся «Концепция Түндүк» | В рабочем режиме название продукта и реальные версии; подпись концепции только в макете/демо |
|
||||
| UI-33 | P1 | Открытие кодекса в лаборатории растягивает страницу: footer оказался на y≈636935 px, сохранение — внизу после него | Ограничить область чтения по высоте viewport и дать ей собственную вертикальную прокрутку; сохранение доступно независимо от длины документа, без сотен экранов прокрутки |
|
||||
|
||||
Escape и возврат фокуса кабинета на `search-account-open` проверены и работают.
|
||||
Это поведение сохранить. Механизм Tab/Shift+Tab всех окон, ошибки API,
|
||||
контраст всех состояний, печатная вёрстка и поведение на 200–400% zoom
|
||||
требуют отдельного приёмочного прогона; здесь они не объявлены проверенными.
|
||||
|
||||
## 4. Целевая сетка, изменение ширины и фильтры
|
||||
|
||||
Это согласованное направление корректировки, а не уже существующие функции.
|
||||
|
||||
1. Шапка, навигация, main и footer принадлежат одной оболочке. Поисковый
|
||||
и лабораторный контент не вставляются друг в друга и не дублируют footer.
|
||||
2. На широком desktop три колонки с двумя доступными разделителями.
|
||||
Базовый ориентир при 1440: **280 / 408 / 736 px + два захвата по 8 px**.
|
||||
При 1280: **256 / 384 / 624 px + два захвата по 8 px**. Это размеры
|
||||
целевых макетов, а не требование сжимать эти колонки ниже удобных минимумов.
|
||||
3. Предлагаемые пределы: фильтры 256–360 px, выдача от 360 px,
|
||||
документ от 560 px. Если окно не вмещает минимумы, фильтры уходят
|
||||
в отдельную панель, затем документ становится отдельным представлением.
|
||||
Пользовательское растягивание не может сломать соседнюю колонку.
|
||||
4. Разделители: узнаваемый захват, `col-resize`, роль `separator`,
|
||||
доступное имя, текущая/минимальная/максимальная ширина. Перетаскивание
|
||||
и стрелки клавиатуры; отдельные «Скрыть фильтры» / «Показать фильтры»
|
||||
и «Сбросить ширину». Двойной клик может дополнять, но не заменяет
|
||||
видимый и клавиатурный способ сброса. Положение хранится локально;
|
||||
при восстановлении ограничивается фактическим viewport.
|
||||
5. На desktop у фильтров нет отдельной постоянно прокручиваемой узкой
|
||||
колонки: краткие группы и раскрытие длинных справочников. Полный
|
||||
каталог доступен через поиск/раскрытие; нельзя скрыть значения навсегда.
|
||||
Одиночный выбор API не выдаётся за множественный checkbox.
|
||||
6. Длинные названия органов открываются в панели выбора с полными строками;
|
||||
основное поле не становится бессмысленной обрезанной подписью.
|
||||
7. Даты имеют постоянные «От» / «До»; если два поля не помещаются,
|
||||
ставятся друг под другом. Перенос текста внутри input недопустим.
|
||||
8. На desktop самостоятельная вертикальная прокрутка выдачи и длинного
|
||||
документа допустима. Их заголовки/toolbar доступны при чтении.
|
||||
Фильтры и всё приложение не должны требовать горизонтальной прокрутки.
|
||||
На телефоне — одна основная вертикальная прокрутка и отдельные панели.
|
||||
9. Footer внизу короткой страницы через layout с `min-height: 100dvh`
|
||||
и растущим main, а не `position: fixed` поверх содержимого. При длинном
|
||||
контенте footer следует после него. Панель сохранения имеет собственное
|
||||
место; sticky допускается только без перекрытия последней строки.
|
||||
|
||||
## 5. Единые правила всех окон и форм
|
||||
|
||||
- Видимый короткий заголовок и крестик в правом верхнем углу, область 44×44.
|
||||
Закрытие не спрятано в конце длинного содержимого.
|
||||
- Desktop: ширина по задаче, обычно 480–640 px, максимум относительно
|
||||
viewport; mobile: отступ 16 px либо полноэкранная форма для длинного ввода.
|
||||
Заголовок/закрытие остаются доступны при прокрутке содержимого.
|
||||
- `dialog` / `aria-modal`, имя через заголовок; фокус при открытии внутри,
|
||||
Tab/Shift+Tab не уходят в фон, Escape закрывает обычное окно, фокус
|
||||
возвращается на инициатор. Фон не интерактивен. Исключения для
|
||||
обязательной смены пароля объясняются пользователю, не маскируются.
|
||||
- Не наслаивать кабинет и пользователей. Переход между задачами — замена
|
||||
поверхности с понятным возвратом. Подтверждение удаления — короткое
|
||||
целевое окно, а не второй длинный редактор за первым.
|
||||
- «Профиль» — просмотр сведений; «Безопасность» — смена пароля. Доступ
|
||||
к администрированию определяется сервером, редизайн не меняет права.
|
||||
- В форме пароля постоянные подписи, подсказка требований, показать/скрыть
|
||||
пароль с доступным именем; подтверждение нового пароля как защита от
|
||||
опечатки. Никакие реальные пароли не предзаполняются и не записываются
|
||||
в материалы аудита. Изменение пароля выполняет человек.
|
||||
- Основное действие одно; «Отмена» вторична; «Выйти» отдельно от сохранения.
|
||||
Enter отправляет только активную форму. Ошибки рядом с полем и в
|
||||
доступном сообщении, введённые допустимые данные сохраняются.
|
||||
- При несохранённом редактировании — понятная защита от случайной потери;
|
||||
обычное закрытие окна просмотра не требует лишнего подтверждения.
|
||||
- Loading исключает повторную отправку; при успехе сообщается результат;
|
||||
при ошибке есть путь восстановления. Валидация не ограничивается цветом.
|
||||
- Эти правила распространяются на кабинет, вход, обязательную смену пароля,
|
||||
источники, создание/сброс/удаление пользователя, обучение и документный viewer.
|
||||
|
||||
Основания для доступного поведения:
|
||||
[WAI-ARIA Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/),
|
||||
[Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
|
||||
(паттерн APG находится в разработке; использовать как ориентир),
|
||||
[WCAG Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html).
|
||||
Крестик и структура кабинета — также прямое требование пользователя.
|
||||
|
||||
## 6. Реализация и проверяемое покрытие
|
||||
|
||||
Кодовая реализация сгруппирована по общей причине, а не по числу замечаний.
|
||||
Ниже перечислены все UI-01—UI-33, привязанные к коду и регрессионному
|
||||
сценарию. Playwright fixture использует только условные записи `admin`,
|
||||
`fixture-user` и `fixture-created`; browser requests полностью перехватываются
|
||||
тестом и не обращаются к приложению, Gitea или production API. Локальный browser
|
||||
run завершился успешно.
|
||||
|
||||
| Пункты | Изменение | Регрессионное покрытие |
|
||||
|---|---|---|
|
||||
| UI-01—02 | Разделены initial/loading/results/empty/error; панель документа показывает инструменты после выбора редакции | Пустой результат, API ошибка и редакция без текста |
|
||||
| UI-03—08 | Скомпонованы фильтры и сворачиваемые группы, читаемые счётчики и языковые select; навигация адаптируется на промежуточной ширине | Reflow 320/390/640/1024/1280/1440 CSS px; на 640 px проверены кабинет, выход и создание пользователя на `/`, `/review`, `/users` |
|
||||
| UI-09—10 | Кабинет подписан на desktop; экран переходит от трёх колонок к адаптивной компоновке | Геометрия действий и отсутствие page overflow на 640 px; layout измерен на 1024/1280/1440 px |
|
||||
| UI-11—16 | Breadcrumb не дублирует заголовок; toolbar переносится; широкие таблицы имеют собственный scroll; URL-атрибуты очищаются; разворот остаётся в панели документа | Fixture удаляет `srcset`, `ping`, private-IP `href/src`, относительные и HTTP ссылки, сохраняя текст и официальную HTTPS-ссылку; лаборатория проверяет A → ошибка B → успешная B и устаревший ответ A |
|
||||
| UI-17 | Переведены подписи поиска, фильтров, кабинета и `/users`; язык документа остаётся независимым | Переключение RU/KY и язык редакции; полный ручной лингвистический review остаётся открытым |
|
||||
| UI-18—20 | Убрано дублирование footer, согласованы контейнеры, лаборатория ограничена по высоте документа | Fixture проверяет один видимый footer; панель 612 px при viewport 900 px и scrollHeight 41027 px |
|
||||
| UI-21—24 | Кабинет имеет имя и разделы профиля/безопасности; переход к пользователям закрывает кабинет и открывает отдельный маршрут | ARIA имя, Escape, focus trap/return и отдельные маршруты |
|
||||
| UI-25—28 | Управление пользователями — отдельный экран; создание и удаление — отдельные окна; сброс и история раскрываются по запросу; общие крестики | Fixture выполнил смену пароля, создание, сброс пароля и удаление только `fixture-created`; production учётные записи и API не использовались |
|
||||
| UI-29 | Обучение запускается пользователем, без автоматического перехвата фокуса | Регрессионный сценарий обучения |
|
||||
| UI-30—31 | Оценка 2×2, комментарий скрываемый; сохранение выключено до выбора результата, есть состояние черновика | Fixture проверяет черновик и фиктивное сохранение; рейтинг и комментарий сохраняются при переключении документа, включая ошибку B и устаревший ответ A |
|
||||
| UI-32—33 | Footer содержит продукт и версии; документ лаборатории имеет собственную прокрутку до панели сохранения | Версии в обоих footer; длинный текст прокручивается внутри панели, сохранение и footer остаются ниже неё |
|
||||
|
||||
Локальный Playwright run прошёл на **320×800, 390×844, 640×450, 1024×900,
|
||||
1280×800 и 1440×900 CSS px**; горизонтального переполнения документа нет.
|
||||
При 640 CSS px кабинет, выход и создание пользователя остаются видимыми и
|
||||
помещаются в viewport на трёх маршрутах. Это проверка reflow, не проверка
|
||||
browser zoom: в доступном headless Chromium Ctrl+Plus не меняет CSS viewport;
|
||||
реальный zoom 200–400% остаётся непроверенным.
|
||||
При 1280 px сетка измерена как 256 / 384 / 640 px. Для длинной лабораторной
|
||||
редакции панель ограничена 544 px при viewport 800 px, scrollHeight 27278 px;
|
||||
сохранение и footer остаются ниже неё в DOM и доступны независимо от текста.
|
||||
Снимки, overlay и pixel-difference с целевым изображением сохранены в
|
||||
[каталоге visual review](visual-review/). Растровый diff является обзорной
|
||||
диагностикой, а не пороговой проверкой: макет и fixture-контент различаются.
|
||||
Ручной лингвистический review всех скрытых KY-подписей, физический touch,
|
||||
200–400% zoom и операции с реальными пользователями/паролями не выполнялись.
|
||||
|
||||
Не мигрировать существующий Python frontend на React/Next.js ради этих
|
||||
исправлений. Не менять ранжирование, корпус или права доступа в UI-задаче.
|
||||
|
||||
## 9. Follow-up замечания владельца: поиск, кабинет и лаборатория
|
||||
|
||||
Повторный просмотр трёх экранов выявил отдельные проблемы уже после PR #54.
|
||||
Исправления остаются в этом плане, чтобы не создавать параллельный список:
|
||||
|
||||
| Поверхность | Наблюдение | Реализация и проверка |
|
||||
|---|---|---|
|
||||
| Поиск: выдача и чтение | Метка «Открыт» ставилась до ответа, могла оставаться после ошибки и дублироваться; оглавление пропускало структурные заголовки из жирных `p > b > span`; выбор языка был обрезан и расположен в навигации; в фильтрах оставался декоративный SVG с пустым резервом; активный пункт «Поиск» визуально смещался | Метка очищается при смене результата/редакции и появляется только после успешной загрузки; оглавление распознаёт самостоятельные заголовки раздела/главы/статьи на RU/KY, назначает уникальные якоря и строит вложенные списки; язык поиска находится в фильтрах с полной шириной; SVG и его место удалены; ссылки навигации используют палитру Акылдаш. Проверка: ошибки и гонки загрузки, заголовки RU/KY и обычные упоминания, уникальные якоря, диапазон и доступность splitter |
|
||||
| Личный кабинет | Навигация выглядела ссылкой браузера; закрытие занимало строку текстом; подписи и значения профиля стояли в столбик; заголовок был крупным; появлялось горизонтальное переполнение; действие управления пользователями было неочевидным | Ссылки кабинета оформлены как действия с сохранением семантики `<a>`; крестик остаётся SVG-кнопкой 44×44 с доступной подписью; профиль выровнен в пары «поле — значение»; заголовок компактнее; длинные значения переносятся; администратор получает явную ссылку «Управление пользователями». Проверка: RU/KY, скрытие ссылки для не-администратора, фокус/закрытие диалога и ширина содержимого |
|
||||
| Лаборатория | Дублировалась роль рядом с именем; ссылки навигации не имели вида кнопок; оболочка, форма, рабочая область, сохранение и footer имели разные границы; пустое сообщение резервировало высоту; панели выглядели разрозненными карточками; основной заголовок переносился; мобильный экран наследовал искусственную высоту | В шапке показывается только имя, роль остаётся в профиле; навигация сохраняет ссылки и получает единый вид кнопок; все участки выровнены по общему shell; пустой live status не добавляет зазор; результаты и документ образуют общую поверхность с внутренней прокруткой на desktop; заголовок использует доступную ширину и размеры по макету Figma; mobile возвращается к естественному потоку с прокруткой документа. Проверка: desktop/mobile, пустой status, сохранение после длинного документа и отсутствие page overflow |
|
||||
|
||||
Frontend повышен до **0.3.0** из-за добавления доступного изменения ширины
|
||||
панелей. Backend и его API не менялись. Fixture данные искусственные; preview
|
||||
слушает только loopback, запрещает запросы за пределы своего origin и не
|
||||
использует авторизационные cookies или production API. Браузерный прогон прошёл
|
||||
на 24 октября 2026 года; пять скриншотов сохранены в `current-progress` рядом
|
||||
с отчётом assignment. CI и независимое ревью проверяются для конкретного pushed
|
||||
SHA.
|
||||
|
||||
## 7. Приёмка: что значит «вылизанный интерфейс»
|
||||
|
||||
| Проверка | Условие |
|
||||
|---|---|
|
||||
| Оболочка | Один видимый header и footer на каждом маршруте, единые границы блоков |
|
||||
| Фильтры | Нет горизонтального скролла; полный текст действий, читаемые подписи и счётчики |
|
||||
| Начальное состояние | Не сообщает об отсутствии результатов до первого поиска |
|
||||
| Документ | Нет пустых селектов и активных бесполезных действий до выбора |
|
||||
| Колонки | Drag и клавиатура работают; min/max соблюдаются; скрытие/возврат и сброс сохраняют контекст |
|
||||
| Телефон | Основные действия видимы без горизонтального скролла, есть возврат из документа/фильтров |
|
||||
| Окна | Заголовок, крестик, Escape, trap/return focus; нет невидимого окна поверх целевого |
|
||||
| Лаборатория | Поиск → чтение → оценка → черновик → сохранение, панель до footer, данные не теряются |
|
||||
| Локализация | RU/KY-подписи, Ө ө Ү ү Ң ң, язык UI не подменяет язык документа |
|
||||
| Типографика | Реально загрузились Noto, основной текст 17–18 px / 1.6–1.7, длинные названия читаемы |
|
||||
| Таблицы | Собственная обозначенная прокрутка, основной текст и controls без обрезки |
|
||||
| Доступность | Контраст WCAG AA, видимый фокус, Tab/Shift+Tab/Enter/Escape, zoom/reflow, reduced motion |
|
||||
| Авторизация | Проверены вход, неверный пароль, истечение сессии и обязательная смена в безопасном тестовом окружении |
|
||||
| Снимки | 1440×900, 1280×800, 1024×768, 390×844; дополнительно 320 CSS px для reflow |
|
||||
|
||||
Для визуального сравнения использовать изолированный детерминированный fixture
|
||||
и одинаковые состояния, а не изменчивую выдачу. Fixture не включается в
|
||||
production-путь и не обходит авторизацию. Сравнивать отдельно шапку,
|
||||
фильтры, выдачу, документ, окна, сохранение и footer. Сглаживание текста
|
||||
не требует нулевого pixel diff; совпадение растра не оправдывает обрезку,
|
||||
неработающий control или нечитаемый реальный контент.
|
||||
|
||||
Локальные проверки, указанные в PR, включая Playwright, прошли. Changelog
|
||||
обновлён в этой ветке. Дальше остаются CI и ручное PR review по обычному
|
||||
процессу репозитория; автоматический reviewer не запускается. Production не
|
||||
меняется автоматически.
|
||||
|
||||
## 8. Материалы и оставшаяся проверка
|
||||
|
||||
Figma содержит целевые редактируемые состояния, а не исправленный live-сайт
|
||||
и не доказательство работоспособности функций. Учётные записи в ней
|
||||
обезличены, текст документа помечен как пример оформления.
|
||||
|
||||
| Figma-экран | Ссылка |
|
||||
|---|---|
|
||||
| 01. Поиск с документом, 1440×900 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-2) |
|
||||
| 02. Фильтры скрыты | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-173) |
|
||||
| 03. Начальный поиск | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-288) |
|
||||
| 04. Лаборатория и доступное сохранение | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-395) |
|
||||
| 05. Кабинет: профиль и закрытие | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-30) |
|
||||
| 06. Кабинет: безопасность | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-229) |
|
||||
| 07. Источники | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-428) |
|
||||
| 08. Пользователи: отдельный экран | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-605) |
|
||||
| 09–11. Телефон: выдача, чтение, фильтры | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-670) |
|
||||
| 12. Эталон 1280×800 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-74) |
|
||||
| 13. Планшет: две панели | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-245) |
|
||||
| 14. Кабинет на телефоне | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-360) |
|
||||
| 15. Вход, создание и подтверждение удаления | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=8-118) |
|
||||
|
||||
Макеты построены редактируемыми текстами, Auto Layout, SVG, локальными
|
||||
переменными цветов/отступов и экземплярами общих кнопок; целый интерфейс
|
||||
не вставлен одной картинкой. Прокрутка длинной выдачи/текста в Figma
|
||||
обозначает намеренное продолжение содержимого. Drag-разделители,
|
||||
закрытие и формы — спецификация будущего поведения, не работающий web-код.
|
||||
|
||||
Локальные экспорты целевых экранов для просмотра без Figma:
|
||||
[поиск](../design/tunduk-search-target.png),
|
||||
[лаборатория](../design/tunduk-review-target.png),
|
||||
[кабинет](../design/tunduk-account-target.png).
|
||||
|
||||
Контрольные свидетельства live-сайта:
|
||||
[поиск с кодексом](../design/audit-2026-10-23/search-document-1440.png),
|
||||
[мобильный поиск](../design/audit-2026-10-23/search-mobile-390.png),
|
||||
[фильтры телефона](../design/audit-2026-10-23/filters-mobile-390.png),
|
||||
[кабинет](../design/audit-2026-10-23/account-desktop.png),
|
||||
[кабинет телефона](../design/audit-2026-10-23/account-mobile-390.png),
|
||||
[лабораторный документ](../design/audit-2026-10-23/review-document-1440.png).
|
||||
|
||||
Browser evidence текущей ветки:
|
||||
[desktop 1280×800](visual-review/desktop-1280x800.png),
|
||||
[mobile 390 px](visual-review/mobile-390px.png),
|
||||
[Figma overlay](visual-review/overlay-50.png) и
|
||||
[pixel difference](visual-review/pixel-difference.png). Overlay показывает
|
||||
различия компоновки между live-target frame и deterministic fixture; pixel
|
||||
diff не используется как pass/fail.
|
||||
|
||||
Регрессия UI10: сетка поиска остаётся двухпанельной на ширине 1101 и 1175 px;
|
||||
третья панель включается при 1176 px, когда минимальные ширины трёх колонок
|
||||
помещаются. Проверка также меняет ширину окна при скрытых фильтрах и проверяет
|
||||
отсутствие горизонтального переполнения. Реальные browser zoom и экспертиза
|
||||
юридической корректности по законодательству не подтверждены этими тестами.
|
||||
|
||||
Не проверялись реальные destructive/auth-сценарии: вход с неверным паролем,
|
||||
смена пароля, удаление/создание пользователей и отправка оценки в production.
|
||||
Наличие макета не считается доказательством этих функций.
|
||||
@@ -44,4 +44,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.3 · Frontend v0.1.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
|
||||
@@ -0,0 +1,100 @@
|
||||
# Приёмка поискового экрана и лаборатории
|
||||
|
||||
Проверка выполнена 23 октября 2026 года в Chromium 156.0.8078.4, Playwright
|
||||
1.64.1, headless, device scale factor 1. Макеты проверены при 1280×800,
|
||||
1440×900, 1024×900 и 390×844 CSS px; reflow дополнительно проверен при
|
||||
320×800 CSS px. Среда: Ubuntu 26.04 LTS под WSL2 Linux x86_64, Chromium
|
||||
156.0.8078.4 headless, масштаб 100%, device scale factor 1.
|
||||
|
||||
## Снимки
|
||||
|
||||
- [1280×800 — основной экран](desktop-1280x800.png)
|
||||
- [1440 px](desktop-1440px.png)
|
||||
- [1024 px](desktop-1024px.png)
|
||||
- [390 px — мобильный просмотр документа](mobile-390px.png)
|
||||
- [Лаборатория `/review` — подсказка на desktop](review-tour-desktop.png)
|
||||
- [Лаборатория `/review` — подсказка на mobile](review-tour-mobile.png)
|
||||
- [Overlay 50% с приложенным референсом](overlay-50.png)
|
||||
- [Карта абсолютной разницы пикселей](pixel-difference.png)
|
||||
|
||||
Покропленные сравнения областей для отдельного просмотра:
|
||||
|
||||
| Область | Overlay | Разница пикселей |
|
||||
|---|---|---|
|
||||
| Шапка и навигация | [Overlay](region-header-overlay.png) | [Diff](region-header-difference.png) |
|
||||
| Фильтры | [Overlay](region-filters-overlay.png) | [Diff](region-filters-difference.png) |
|
||||
| Результаты | [Overlay](region-results-overlay.png) | [Diff](region-results-difference.png) |
|
||||
| Документ | [Overlay](region-document-overlay.png) | [Diff](region-document-difference.png) |
|
||||
| Footer | [Overlay](region-footer-overlay.png) | [Diff](region-footer-difference.png) |
|
||||
|
||||
## Фактическая проверка
|
||||
|
||||
- Снимок референса 1586×992 пропорционально вписан в 1280×800. Границы
|
||||
крупных зон расходятся с реализацией не более чем на 1 CSS px: колонки
|
||||
x=0/226/648/1280; рабочая область начинается около y=126, заканчивается
|
||||
на y=744; footer — y=744–800.
|
||||
- Вторая строка навигации измерена по макету: поиск x=26–123, «Источники»
|
||||
x=146–245, язык x=285–373, статус x=381–486, начало демо-подписи x=511.
|
||||
Референсная подпись и элементы попадают в эти координаты с расхождением
|
||||
около 1–2 px; у статуса остаётся несколько лишних пикселей по ширине.
|
||||
- Горизонтального переполнения при 1440, 1024 и 390 px нет.
|
||||
- При 320 px также нет горизонтального переполнения.
|
||||
- Загружены Noto Sans и Noto Serif, включая кириллические наборы; повторяющихся
|
||||
HTML id нет, ошибок JavaScript в сценарии не зарегистрировано.
|
||||
- В fixture проверены ответы 200 для SVG и PNG favicon; локализация проверена
|
||||
на фильтрах, результатах, счётчике совпадений и открытом статусе.
|
||||
- Мобильная панель фильтров удерживает фокус в цикле, делает фон inert,
|
||||
показывает 3 px focus ring, закрывается по Escape и возвращает фокус на
|
||||
кнопку открытия.
|
||||
- Playwright проверил сохранение второго фильтра при удалении первого, URL,
|
||||
открытие редакции, поиск по документу
|
||||
(1-е и 2-е совпадения), независимость языка текста от языка интерфейса,
|
||||
отображение поисковых подписей и выбранного статуса на кыргызском,
|
||||
разворачивание документа, действия профиля/пользователей/выхода, мобильную
|
||||
панель и возврат к результатам, изоляцию `/review`, ошибки поиска и редакции,
|
||||
повтор после ошибки и отсутствие доступных редакций.
|
||||
- Проверены print CSS (служебные панели скрыты, документ и официальный источник
|
||||
остаются), горизонтальная прокрутка широкой таблицы и изображения внутри
|
||||
документа без общей прокрутки страницы, длинные названия, `prefers-reduced-motion`,
|
||||
ошибки/повтор после ошибки, состояние без редакций, загрузка и сохранение
|
||||
остальных URL-фильтров при удалении одного.
|
||||
- Для фильтров и открытых `<dialog>` проверены Tab/Shift+Tab, Escape и возврат
|
||||
фокуса. При Tab с последней кнопки в кабинете обнаружено и исправлено
|
||||
выпадение фокуса на `body`; повторный прогон подтверждает цикл внутри диалога.
|
||||
Полный скринридерный аудит этим не заменяется.
|
||||
- Обучающая подсказка `/review` проверена на всех четырёх шагах при 1280×800
|
||||
и 390×844: карточка не шире 320 px, использует Noto Sans/Noto Serif,
|
||||
полупрозрачный фон и располагается у целевого элемента без пересечения.
|
||||
Снимки первого шага сохранены для desktop и mobile.
|
||||
|
||||
Для снимков Playwright перехватывал API и отдавал локальные детерминированные
|
||||
fixture-ответы. Подпись «Демонстрационные данные» добавлялась только в тестовой
|
||||
странице перед снимком. Эти юридические тексты и результаты не входят в код
|
||||
продукта. Для overlay и difference исходный растр 1586×992 вписывается
|
||||
в 1280×800 с сохранением пропорций и полем около 1 px по бокам. В эталоне
|
||||
нет кнопки кабинета; в интерфейсе она сохранена для доступности действий
|
||||
учётной записи и остаётся заметным дополнительным элементом справа во второй
|
||||
строке. Макет показывает язык поиска как удаляемый чип, интерфейс использует
|
||||
доступный select; это функциональное расхождение пока оставлено явно видимым.
|
||||
Содержимое документа, системный формат даты и сглаживание текста также не
|
||||
совпадают попиксельно с концептом.
|
||||
|
||||
## Повторный запуск
|
||||
|
||||
Из корня репозитория выполните `cd tools/frontend-visual-review`, затем
|
||||
`npm ci`, `npx playwright install chromium` и `npm test`. Скрипт читает текущий
|
||||
`backend/search/review.html`, перехватывает API детерминированными fixture-
|
||||
ответами и обновляет перечисленные выше снимки. Overlay и pixel-difference
|
||||
создаются, если найден исходный файл по стандартному пути или передан путь в
|
||||
переменной `REFERENCE_IMAGE`.
|
||||
|
||||
## Оставшиеся визуальные отличия
|
||||
|
||||
Сглаживание текста и содержимое документа отличаются от растрового макета.
|
||||
Поля даты показывают системный формат Chromium. Переключатель языка интерфейса
|
||||
и поле языка поиска остаются разными состояниями; в макете язык показан как
|
||||
удаляемый чип. Дополнительная кнопка кабинета отсутствует в концепте. Не
|
||||
проверялись полный набор ошибок авторизации, скринридер, видимый фокус на каждом
|
||||
шаге, длинные реальные редакции и таблицы из корпуса. Автоматические снимки
|
||||
показывают композицию, но не подтверждают юридическое содержимое или доступность
|
||||
во всех браузерах.
|
||||
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -19,6 +19,12 @@ ENV_FILE = Path("/etc/akyldash/production.env")
|
||||
BACKUP_DIR = Path("/var/backups/akyldash")
|
||||
BACKUP_TOOL = Path("/usr/local/lib/akyldash/archive.py")
|
||||
APP_SERVICES = {"backend", "opensearch"}
|
||||
IMAGE_TAG_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*")
|
||||
IMAGE_DIGEST_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}")
|
||||
|
||||
|
||||
def is_digest_reference(reference: str) -> bool:
|
||||
return IMAGE_DIGEST_RE.fullmatch(reference) is not None
|
||||
|
||||
|
||||
def image_restore_plan(manifest: dict, configured: dict[str, str]) -> dict[str, tuple[str, str]]:
|
||||
@@ -34,14 +40,48 @@ def image_restore_plan(manifest: dict, configured: dict[str, str]) -> dict[str,
|
||||
image_id = image.get("image_id")
|
||||
if reference != configured.get(service):
|
||||
raise ValueError(f"Compose image reference changed for {service}; refusing rollback")
|
||||
if not isinstance(reference, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/-]*", reference):
|
||||
raise ValueError(f"{service} image reference is not a local tag")
|
||||
if not isinstance(reference, str) or not (
|
||||
IMAGE_TAG_RE.fullmatch(reference) or is_digest_reference(reference)
|
||||
):
|
||||
raise ValueError(f"invalid image reference for {service}")
|
||||
if not isinstance(image_id, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
||||
raise ValueError(f"invalid previous image ID for {service}")
|
||||
plan[service] = (image_id, reference)
|
||||
return plan
|
||||
|
||||
|
||||
def verify_previous_images(plan: dict[str, tuple[str, str]]) -> None:
|
||||
for image_id, reference in plan.values():
|
||||
try:
|
||||
actual = run(["docker", "image", "inspect", "--format", "{{.Id}}", image_id], capture=True)
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise ValueError(f"previous image is unavailable: {image_id}") from error
|
||||
if actual != image_id:
|
||||
raise ValueError(f"previous image is unavailable: {image_id}")
|
||||
if is_digest_reference(reference):
|
||||
try:
|
||||
referenced_id = run(
|
||||
["docker", "image", "inspect", "--format", "{{.Id}}", reference], capture=True
|
||||
)
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise ValueError(f"previous digest reference is unavailable: {reference}") from error
|
||||
if referenced_id != image_id:
|
||||
raise ValueError(f"previous digest reference does not match its recorded image: {reference}")
|
||||
|
||||
|
||||
def require_tag_rollback(plan: dict[str, tuple[str, str]]) -> None:
|
||||
if any(is_digest_reference(reference) for _image_id, reference in plan.values()):
|
||||
raise ValueError(
|
||||
"rollback of digest-pinned images is not supported safely; no services or image references were changed"
|
||||
)
|
||||
|
||||
|
||||
def tag_previous_images(plan: dict[str, tuple[str, str]]) -> None:
|
||||
require_tag_rollback(plan)
|
||||
for image_id, reference in plan.values():
|
||||
run(["docker", "image", "tag", image_id, reference])
|
||||
|
||||
|
||||
def run(command: list[str], *, capture: bool = False) -> str:
|
||||
result = subprocess.run(command, check=True, text=True, capture_output=capture)
|
||||
return result.stdout.strip() if capture else ""
|
||||
@@ -96,10 +136,8 @@ def rollback(backup: Path, data_compatible: bool) -> None:
|
||||
rendered = json.loads(compose("config", "--format", "json", capture=True))
|
||||
configured = {service: rendered["services"][service]["image"] for service in APP_SERVICES}
|
||||
plan = image_restore_plan(manifest, configured)
|
||||
for image_id, _reference in plan.values():
|
||||
actual = run(["docker", "image", "inspect", "--format", "{{.Id}}", image_id], capture=True)
|
||||
if actual != image_id:
|
||||
raise ValueError(f"previous image is unavailable: {image_id}")
|
||||
verify_previous_images(plan)
|
||||
require_tag_rollback(plan)
|
||||
|
||||
backup_name = f"akyldash-pre-rollback-{dt.datetime.now(dt.timezone.utc):%Y%m%dT%H%M%SZ}.tar.gz"
|
||||
backup_output = BACKUP_DIR / backup_name
|
||||
@@ -123,8 +161,7 @@ def rollback(backup: Path, data_compatible: bool) -> None:
|
||||
compose("up", "--detach")
|
||||
raise
|
||||
|
||||
for image_id, reference in plan.values():
|
||||
run(["docker", "image", "tag", image_id, reference])
|
||||
tag_previous_images(plan)
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
run(
|
||||
[
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from validate_release import validate_manifest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
REPOSITORIES = {
|
||||
"backend": ["registry.invalid/akyldash/backend"],
|
||||
"opensearch": ["registry.invalid/akyldash/opensearch"],
|
||||
}
|
||||
|
||||
|
||||
class ReleaseManifestTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.policy = {"schema_version": 1, "repositories": REPOSITORIES}
|
||||
self.manifest = {
|
||||
"schema_version": 1,
|
||||
"source_ref": "refs/heads/main",
|
||||
"commit": "a" * 40,
|
||||
"images": {
|
||||
"backend": "registry.invalid/akyldash/backend@sha256:" + "b" * 64,
|
||||
"opensearch": "registry.invalid/akyldash/opensearch@sha256:" + "c" * 64,
|
||||
},
|
||||
}
|
||||
|
||||
def test_accepts_both_policy_pinned_images(self):
|
||||
self.assertEqual(validate_manifest(self.manifest, self.policy), self.manifest)
|
||||
|
||||
def test_rejects_untrusted_ref(self):
|
||||
self.manifest["source_ref"] = "refs/pull/12/head"
|
||||
with self.assertRaisesRegex(ValueError, "source_ref"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
def test_rejects_tag_instead_of_digest(self):
|
||||
self.manifest["images"]["backend"] = "registry.invalid/akyldash/backend:latest"
|
||||
with self.assertRaisesRegex(ValueError, "sha256"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
def test_rejects_empty_image_reference(self):
|
||||
self.manifest["images"]["backend"] = ""
|
||||
with self.assertRaisesRegex(ValueError, "sha256"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
def test_rejects_invalid_commit(self):
|
||||
self.manifest["commit"] = "not-a-commit"
|
||||
with self.assertRaisesRegex(ValueError, "commit"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
def test_rejects_repository_outside_policy(self):
|
||||
self.manifest["images"]["backend"] = "registry.invalid/other/backend@sha256:" + "b" * 64
|
||||
with self.assertRaisesRegex(ValueError, "not allowed"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
def test_rejects_missing_or_extra_images(self):
|
||||
self.manifest["images"].pop("opensearch")
|
||||
with self.assertRaisesRegex(ValueError, "exactly backend and opensearch"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
self.manifest["images"]["opensearch"] = "registry.invalid/akyldash/opensearch@sha256:" + "c" * 64
|
||||
self.manifest["images"]["extra"] = "registry.invalid/akyldash/extra@sha256:" + "d" * 64
|
||||
with self.assertRaisesRegex(ValueError, "exactly backend and opensearch"):
|
||||
validate_manifest(self.manifest, self.policy)
|
||||
|
||||
def test_rejects_duplicate_json_keys(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
manifest_path = Path(directory) / "manifest.json"
|
||||
policy_path = Path(directory) / "policy.json"
|
||||
manifest_path.write_text(
|
||||
'{"schema_version":1,"source_ref":"refs/heads/main","commit":"' + "a" * 40
|
||||
+ '","images":{"backend":"first","backend":"second","opensearch":"third"}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
policy_path.write_text(json.dumps(self.policy), encoding="utf-8")
|
||||
command = [
|
||||
"python3", str(ROOT / "infra/deploy/validate_release.py"), str(manifest_path),
|
||||
"--policy", str(policy_path),
|
||||
]
|
||||
result = subprocess.run(command, text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("duplicate JSON key: backend", result.stderr)
|
||||
|
||||
def test_rejects_malformed_json(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
manifest_path = Path(directory) / "manifest.json"
|
||||
policy_path = Path(directory) / "policy.json"
|
||||
manifest_path.write_text('{"schema_version":', encoding="utf-8")
|
||||
policy_path.write_text(json.dumps(self.policy), encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
["python3", str(ROOT / "infra/deploy/validate_release.py"), str(manifest_path), "--policy", str(policy_path)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("cannot read valid JSON", result.stderr)
|
||||
|
||||
@unittest.skipUnless(shutil.which("docker"), "Docker CLI is unavailable")
|
||||
def test_compose_override_renders_digest_images_and_preserves_build(self):
|
||||
backend = "registry.invalid/akyldash/backend@sha256:" + "b" * 64
|
||||
opensearch = "registry.invalid/akyldash/opensearch@sha256:" + "c" * 64
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
temp = Path(directory)
|
||||
secret = temp / "review_secret"
|
||||
secret.touch()
|
||||
override = temp / "release-override.json"
|
||||
override.write_text(json.dumps({"services": {
|
||||
"backend": {"image": backend},
|
||||
"opensearch": {"image": opensearch},
|
||||
}}), encoding="utf-8")
|
||||
command = [
|
||||
"docker", "compose", "--env-file", str(ROOT / "deploy/production/.env.example"),
|
||||
"-f", str(ROOT / "deploy/production/compose.yaml"), "-f", str(override),
|
||||
"config", "--format", "json",
|
||||
]
|
||||
result = subprocess.run(
|
||||
command, text=True, capture_output=True,
|
||||
env={**os.environ, "REVIEW_SECRET_FILE": str(secret)},
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
services = json.loads(result.stdout)["services"]
|
||||
self.assertEqual(services["backend"]["image"], backend)
|
||||
self.assertEqual(services["opensearch"]["image"], opensearch)
|
||||
self.assertIn("build", services["backend"])
|
||||
self.assertIn("build", services["opensearch"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,12 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from rollback import image_restore_plan
|
||||
import rollback
|
||||
from rollback import image_restore_plan, require_tag_rollback, tag_previous_images, verify_previous_images
|
||||
|
||||
|
||||
class RollbackPlanTests(unittest.TestCase):
|
||||
@@ -28,9 +34,96 @@ class RollbackPlanTests(unittest.TestCase):
|
||||
def test_refuses_digest_reference_that_cannot_be_retagged(self):
|
||||
self.manifest["images"]["backend"]["configured"] = "registry/backend@sha256:" + "c" * 64
|
||||
self.configured["backend"] = self.manifest["images"]["backend"]["configured"]
|
||||
with self.assertRaisesRegex(ValueError, "local tag"):
|
||||
plan = image_restore_plan(self.manifest, self.configured)
|
||||
self.assertEqual(plan["backend"], ("sha256:" + "a" * 64, self.configured["backend"]))
|
||||
with self.assertRaisesRegex(ValueError, "not supported safely"):
|
||||
require_tag_rollback(plan)
|
||||
|
||||
def test_refuses_malformed_digest_reference(self):
|
||||
self.manifest["images"]["backend"]["configured"] = "registry/backend@sha256:xyz"
|
||||
self.configured["backend"] = self.manifest["images"]["backend"]["configured"]
|
||||
with self.assertRaisesRegex(ValueError, "invalid image reference"):
|
||||
image_restore_plan(self.manifest, self.configured)
|
||||
|
||||
def test_refuses_invalid_recorded_image_id(self):
|
||||
self.manifest["images"]["backend"]["image_id"] = "sha256:not-a-digest"
|
||||
with self.assertRaisesRegex(ValueError, "invalid previous image ID"):
|
||||
image_restore_plan(self.manifest, self.configured)
|
||||
|
||||
def test_digest_reference_must_resolve_to_recorded_image_id(self):
|
||||
digest = "registry/backend@sha256:" + "c" * 64
|
||||
self.manifest["images"]["backend"]["configured"] = digest
|
||||
self.configured["backend"] = digest
|
||||
plan = image_restore_plan(self.manifest, self.configured)
|
||||
with patch("rollback.run", side_effect=["sha256:" + "a" * 64, "sha256:" + "a" * 64,
|
||||
"sha256:" + "b" * 64]):
|
||||
verify_previous_images(plan)
|
||||
with patch("rollback.run", side_effect=["sha256:" + "a" * 64, "sha256:" + "d" * 64]):
|
||||
with self.assertRaisesRegex(ValueError, "does not match its recorded image"):
|
||||
verify_previous_images(plan)
|
||||
|
||||
def test_missing_previous_image_fails_closed(self):
|
||||
plan = image_restore_plan(self.manifest, self.configured)
|
||||
with patch("rollback.run", side_effect=subprocess.CalledProcessError(1, "docker")):
|
||||
with self.assertRaisesRegex(ValueError, "previous image is unavailable"):
|
||||
verify_previous_images(plan)
|
||||
|
||||
def test_digest_reference_is_never_tagged(self):
|
||||
digest = "registry/backend@sha256:" + "c" * 64
|
||||
plan = {"backend": ("sha256:" + "a" * 64, digest)}
|
||||
with patch("rollback.run") as run_mock:
|
||||
with self.assertRaisesRegex(ValueError, "not supported safely"):
|
||||
tag_previous_images(plan)
|
||||
run_mock.assert_not_called()
|
||||
|
||||
def test_digest_rollback_fails_before_stopping_or_changing_references(self):
|
||||
backend_id = "sha256:" + "a" * 64
|
||||
opensearch_id = "sha256:" + "b" * 64
|
||||
digest = "registry/backend@sha256:" + "c" * 64
|
||||
manifest = {
|
||||
"images": {
|
||||
"backend": {"configured": digest, "image_id": backend_id},
|
||||
"opensearch": self.manifest["images"]["opensearch"],
|
||||
},
|
||||
"deployment_files": {
|
||||
"deploy/production/compose.yaml": "compose-hash",
|
||||
"deploy/production/Caddyfile": "caddy-hash",
|
||||
},
|
||||
}
|
||||
rendered = {
|
||||
"services": {
|
||||
"backend": {"image": digest},
|
||||
"opensearch": {"image": self.configured["opensearch"]},
|
||||
}
|
||||
}
|
||||
fake_archive = SimpleNamespace(
|
||||
digest=lambda path: "compose-hash" if path.name == "compose.yaml" else "caddy-hash",
|
||||
verify_archive=lambda _backup: manifest,
|
||||
)
|
||||
backup = rollback.BACKUP_DIR / "synthetic.tar.gz"
|
||||
with (
|
||||
patch("rollback.Path.is_file", return_value=True),
|
||||
patch("rollback.Path.is_symlink", return_value=False),
|
||||
patch("rollback.Path.stat", return_value=SimpleNamespace(st_uid=0, st_mode=0o100600)),
|
||||
patch("rollback.compose", return_value=json.dumps(rendered)) as compose_mock,
|
||||
patch("rollback.run", side_effect=[backend_id, backend_id, opensearch_id]) as run_mock,
|
||||
patch.dict(sys.modules, {"archive": fake_archive}),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "not supported safely"):
|
||||
rollback.rollback(backup, data_compatible=True)
|
||||
compose_mock.assert_called_once_with("config", "--format", "json", capture=True)
|
||||
self.assertEqual(run_mock.call_count, 3)
|
||||
self.assertTrue(all(call.args[0][:3] == ["docker", "image", "inspect"]
|
||||
for call in run_mock.call_args_list))
|
||||
|
||||
def test_tag_rollback_keeps_existing_image_tag_operation(self):
|
||||
plan = image_restore_plan(self.manifest, self.configured)
|
||||
with patch("rollback.run") as run_mock:
|
||||
tag_previous_images(plan)
|
||||
self.assertEqual(run_mock.call_count, 2)
|
||||
run_mock.assert_any_call(["docker", "image", "tag", "sha256:" + "a" * 64,
|
||||
"akyldash/backend:local"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,6 +14,7 @@ from verify_corpus import (
|
||||
prefix_upper_bound,
|
||||
raw_file_rows,
|
||||
combined_source_hash,
|
||||
NORMALIZER_VERSION,
|
||||
safe_relative_path,
|
||||
validate_fragment_file,
|
||||
verify_corpus,
|
||||
@@ -39,7 +40,7 @@ class CorpusInventoryTests(unittest.TestCase):
|
||||
)
|
||||
connection.close()
|
||||
|
||||
def _nonempty_corpus(self):
|
||||
def _nonempty_corpus(self, normalizer_version=NORMALIZER_VERSION):
|
||||
document = self.raw / "documents/1"
|
||||
edition = document / "editions/2"
|
||||
edition.mkdir(parents=True)
|
||||
@@ -79,7 +80,7 @@ class CorpusInventoryTests(unittest.TestCase):
|
||||
document_json = {
|
||||
"schema_version": "1",
|
||||
"source_code": "1",
|
||||
"normalizer": {"version": "1.0.0"},
|
||||
"normalizer": {"version": normalizer_version},
|
||||
"source": {"path": "documents/1", "files": source_files, "sha256": source_sha},
|
||||
"editions": [{"source_code": "2"}],
|
||||
}
|
||||
@@ -98,7 +99,9 @@ class CorpusInventoryTests(unittest.TestCase):
|
||||
normalized_manifest.execute(
|
||||
"CREATE TABLE documents (code TEXT PRIMARY KEY, source_sha256 TEXT, schema_version TEXT, normalizer_version TEXT, state TEXT)"
|
||||
)
|
||||
normalized_manifest.execute("INSERT INTO documents VALUES ('1', ?, '1', '1.0.0', 'success')", (source_sha,))
|
||||
normalized_manifest.execute(
|
||||
"INSERT INTO documents VALUES ('1', ?, '1', ?, 'success')", (source_sha, normalizer_version)
|
||||
)
|
||||
normalized_manifest.commit()
|
||||
normalized_manifest.close()
|
||||
return normalized, source_sha
|
||||
@@ -273,6 +276,24 @@ class CorpusInventoryTests(unittest.TestCase):
|
||||
self.assertEqual(result["manifest_summary"]["raw"]["documents"], 1)
|
||||
self.assertEqual(result["manifest_summary"]["normalized"]["fragments"], 1)
|
||||
|
||||
def test_verifies_legacy_normalizer_1_0_0_manifest(self):
|
||||
normalized, _ = self._nonempty_corpus(normalizer_version="1.0.0")
|
||||
result = verify_corpus(self.raw, normalized, self.base / "artifacts")
|
||||
self.assertEqual(result["status"], "complete", result["finding_examples"])
|
||||
self.assertNotIn(
|
||||
"normalizer_version_mismatch",
|
||||
{item["kind"] for item in result["finding_examples"]},
|
||||
)
|
||||
|
||||
def test_rejects_unsupported_normalizer_version(self):
|
||||
normalized, _ = self._nonempty_corpus(normalizer_version="9.9.9")
|
||||
result = verify_corpus(self.raw, normalized, self.base / "artifacts")
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn(
|
||||
"normalizer_version_mismatch",
|
||||
{item["kind"] for item in result["finding_examples"]},
|
||||
)
|
||||
|
||||
def test_raw_normalized_source_mismatch_is_reported(self):
|
||||
normalized, _ = self._nonempty_corpus()
|
||||
manifest = sqlite3.connect(normalized / "manifest.sqlite3")
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the shape and repository allowlist of a prepared release manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SERVICES = {"backend", "opensearch"}
|
||||
COMMIT_RE = re.compile(r"[0-9a-f]{40}\Z")
|
||||
REPOSITORY_RE = re.compile(
|
||||
r"[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?"
|
||||
r"(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*\Z"
|
||||
)
|
||||
IMAGE_RE = re.compile(r"(.+)@sha256:([0-9a-f]{64})\Z")
|
||||
|
||||
|
||||
def unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
result: dict[str, object] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError(f"duplicate JSON key: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def read_json(path: Path) -> object:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"cannot read valid JSON from {path}: {error}") from error
|
||||
|
||||
|
||||
def validate_policy(policy: object) -> dict[str, list[str]]:
|
||||
if not isinstance(policy, dict) or set(policy) != {"schema_version", "repositories"}:
|
||||
raise ValueError("policy must contain exactly schema_version and repositories")
|
||||
if type(policy["schema_version"]) is not int or policy["schema_version"] != 1:
|
||||
raise ValueError("unsupported policy schema_version")
|
||||
repositories = policy["repositories"]
|
||||
if not isinstance(repositories, dict) or set(repositories) != SERVICES:
|
||||
raise ValueError("policy repositories must contain exactly backend and opensearch")
|
||||
result: dict[str, list[str]] = {}
|
||||
for service in sorted(SERVICES):
|
||||
allowed = repositories[service]
|
||||
if not isinstance(allowed, list) or not allowed:
|
||||
raise ValueError(f"policy repository allowlist for {service} must be a non-empty list")
|
||||
if any(not isinstance(repository, str) or not REPOSITORY_RE.fullmatch(repository) for repository in allowed):
|
||||
raise ValueError(f"policy contains an invalid repository for {service}")
|
||||
if len(set(allowed)) != len(allowed):
|
||||
raise ValueError(f"policy repository allowlist for {service} contains duplicates")
|
||||
result[service] = allowed
|
||||
return result
|
||||
|
||||
|
||||
def validate_manifest(manifest: object, policy: object) -> dict[str, object]:
|
||||
allowed_repositories = validate_policy(policy)
|
||||
if not isinstance(manifest, dict) or set(manifest) != {
|
||||
"schema_version", "source_ref", "commit", "images"
|
||||
}:
|
||||
raise ValueError("manifest must contain exactly schema_version, source_ref, commit, and images")
|
||||
if type(manifest["schema_version"]) is not int or manifest["schema_version"] != 1:
|
||||
raise ValueError("unsupported manifest schema_version")
|
||||
if manifest["source_ref"] != "refs/heads/main":
|
||||
raise ValueError("manifest source_ref must be refs/heads/main")
|
||||
commit = manifest["commit"]
|
||||
if not isinstance(commit, str) or not COMMIT_RE.fullmatch(commit):
|
||||
raise ValueError("manifest commit must be a 40-character lowercase Git SHA-1")
|
||||
images = manifest["images"]
|
||||
if not isinstance(images, dict) or set(images) != SERVICES:
|
||||
raise ValueError("manifest images must contain exactly backend and opensearch")
|
||||
for service in sorted(SERVICES):
|
||||
image = images[service]
|
||||
match = IMAGE_RE.fullmatch(image) if isinstance(image, str) else None
|
||||
if not match:
|
||||
raise ValueError(f"{service} image must use repository@sha256:<64 lowercase hex>")
|
||||
repository = match.group(1)
|
||||
if not REPOSITORY_RE.fullmatch(repository):
|
||||
raise ValueError(f"{service} image has an invalid OCI repository")
|
||||
if repository not in allowed_repositories[service]:
|
||||
raise ValueError(f"{service} image repository is not allowed by policy")
|
||||
return manifest
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--policy", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
manifest = validate_manifest(read_json(args.manifest), read_json(args.policy))
|
||||
except ValueError as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
images = manifest["images"]
|
||||
print(
|
||||
"PASS release manifest "
|
||||
f"commit={manifest['commit']} backend={images['backend']} opensearch={images['opensearch']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -27,6 +27,7 @@ NORMALIZER_ROOT = Path(__file__).resolve().parents[2]
|
||||
TOOL_SHA256 = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
||||
sys.path.insert(0, str(NORMALIZER_ROOT))
|
||||
from backend.normalization.minjust_cbd import NORMALIZER_VERSION, SCHEMA_VERSION as NORMALIZED_SCHEMA_VERSION # noqa: E402
|
||||
SUPPORTED_NORMALIZER_VERSIONS = frozenset({"1.0.0", NORMALIZER_VERSION})
|
||||
|
||||
|
||||
class InventoryError(RuntimeError):
|
||||
@@ -552,7 +553,7 @@ def load_manifest(
|
||||
finding(connection, run_id, root_id, "normalized_manifest_state", f"documents/{code}", str(state))
|
||||
if root_id == "normalized" and schema_version != NORMALIZED_SCHEMA_VERSION:
|
||||
finding(connection, run_id, root_id, "schema_version_mismatch", f"documents/{code}", str(schema_version))
|
||||
if root_id == "normalized" and normalizer_version != NORMALIZER_VERSION:
|
||||
if root_id == "normalized" and normalizer_version not in SUPPORTED_NORMALIZER_VERSIONS:
|
||||
finding(connection, run_id, root_id, "normalizer_version_mismatch", f"documents/{code}", str(normalizer_version))
|
||||
if root_id == "raw":
|
||||
connection.execute("DELETE FROM manifest_errors WHERE root_id='raw'")
|
||||
|
||||
@@ -179,7 +179,7 @@ TasksMax=256
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectHome=read-only
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
|
||||
@@ -23,6 +23,10 @@ grep -Fq 'test -w "$HOME" && test -w "$XDG_CONFIG_HOME" && test -w "$XDG_DATA_HO
|
||||
grep -Fq 'rootless_env=(HOME="$runner_runtime_home" XDG_CONFIG_HOME="$runner_xdg_config_home" XDG_DATA_HOME="$runner_xdg_data_home"' "$installer"
|
||||
grep -Fq 'Environment=XDG_CONFIG_HOME=$runner_xdg_config_home' "$installer"
|
||||
grep -Fq 'Environment=XDG_DATA_HOME=$runner_xdg_data_home' "$installer"
|
||||
grep -Fq 'Environment=XDG_RUNTIME_DIR=/run/user/$runner_uid' "$installer"
|
||||
grep -Fq 'Environment=DOCKER_HOST=unix:///run/user/$runner_uid/docker.sock' "$installer"
|
||||
grep -Fq 'ProtectHome=read-only' "$installer"
|
||||
assert_absent '^ProtectHome=yes$' "$installer"
|
||||
grep -Fq 'runner_home/.config/systemd/user/docker.service.d/environment.conf' "$installer"
|
||||
grep -Fq 'Environment=HOME=$runner_runtime_home' "$installer"
|
||||
grep -Fq 'systemctl --user restart docker.service' "$installer"
|
||||
@@ -42,6 +46,7 @@ if grep -Fq 'env HOME="$runner_home"' "$installer"; then
|
||||
fi
|
||||
grep -Fq 'die '\''stop akyldash-act-runner.service after all jobs finish before rerunning this installer' "$installer"
|
||||
grep -Fq 'ReadWritePaths=$runner_runtime_home $runner_xdg_config_home /var/log/akyldash-runner /run/user/$runner_uid' "$installer"
|
||||
assert_absent 'ReadWritePaths=.*(/var/run/docker.sock|/run/docker.sock)' "$installer"
|
||||
grep -Fq 'os.O_NOFOLLOW | os.O_CLOEXEC' infra/runner/secure-runner-image.py
|
||||
grep -Fq 'os.posix_fallocate(image_fd, 0, int(bytes_text))' infra/runner/secure-runner-image.py
|
||||
grep -Fq 'pass_fds=(image_fd,)' infra/runner/secure-runner-image.py
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const repo = path.resolve(__dirname, '../..');
|
||||
const artifacts = process.env.VISUAL_REVIEW_ARTIFACTS || path.join(repo, 'docs/product/visual-review');
|
||||
const html = fs.readFileSync(path.join(repo, 'backend/search/review.html'), 'utf8');
|
||||
const root = path.join(repo, 'backend/search/static/fonts');
|
||||
const {docs, reviewDocs, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy} = require('./fixtures.cjs');
|
||||
fs.mkdirSync(artifacts, {recursive: true});
|
||||
let fixtureAccount = passwordChangeAccount;
|
||||
const fixtureState = {users:[{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[]};
|
||||
let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMetadata = false; let failReviewDocumentB = false; let delayReviewDocumentA = false; let delayNextReviewSearch = false;
|
||||
(async()=>{
|
||||
const browser = await chromium.launch({headless:true});
|
||||
const page = await browser.newPage({viewport:{width:1440,height:800},deviceScaleFactor:1});
|
||||
await page.addInitScript(()=>{if(!sessionStorage.getItem('visual-review-splitter-reset')){localStorage.removeItem('akyldash-search-results-share');sessionStorage.setItem('visual-review-splitter-reset','1');}});
|
||||
const errors=[]; page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/*', async route=>{
|
||||
const req=route.request(), url=new URL(req.url());
|
||||
if (req.isNavigationRequest()) return route.fulfill({status:200,contentType:'text/html; charset=utf-8',body:html});
|
||||
const p=url.pathname;
|
||||
if(p==='/auth/me') return route.fulfill({json:{account:fixtureAccount}});
|
||||
if(p==='/search/filters') return route.fulfill({json:catalogs});
|
||||
if(p==='/search-reviews' && req.method()==='POST') { fixtureState.reviewSaves++; fixtureState.lastReviewRatings=req.postDataJSON().results.map((result)=>({code:result.code,rating:result.rating})); return route.fulfill({json:{id:42}}); }
|
||||
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:fixtureAccount}}); }
|
||||
if(p==='/admin/users' && req.method()==='GET') return route.fulfill({json:{users:fixtureState.users}});
|
||||
if(p==='/admin/users' && req.method()==='POST') { const body=req.postDataJSON(); fixtureState.userCreates++; fixtureState.users.push({username:body.username,role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}); return route.fulfill({status:201,json:{username:body.username}}); }
|
||||
const resetMatch=p.match(/^\/admin\/users\/([^/]+)\/reset-password$/);
|
||||
if(resetMatch && req.method()==='POST') { fixtureState.passwordResets++; const username=decodeURIComponent(resetMatch[1]); const user=fixtureState.users.find((candidate)=>candidate.username===username); if(user) user.must_change_password=true; return route.fulfill({json:{username}}); }
|
||||
const deleteMatch=p.match(/^\/admin\/users\/([^/]+)$/);
|
||||
if(deleteMatch && req.method()==='DELETE') { fixtureState.userDeletes++; const username=decodeURIComponent(deleteMatch[1]); fixtureState.users=fixtureState.users.filter((candidate)=>candidate.username!==username); return route.fulfill({json:{}}); }
|
||||
if(p==='/search') {
|
||||
if(url.searchParams.has('page_size')&&!url.searchParams.has('sort')) { if(delayNextReviewSearch){delayNextReviewSearch=false;await new Promise(resolve=>setTimeout(resolve,300));} return route.fulfill({json:{results:reviewDocs,has_next:false,review_token:'fixture-review-token'}}); }
|
||||
return route.fulfill(url.searchParams.get('q')==='service-error' ? {status:503,json:{error:'Fixture search error'}} : {json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false,review_token:'fixture-review-token'}});
|
||||
}
|
||||
if(p==='/documents/doc-2/editions') return route.fulfill({json:{available_languages:[],editions:[]}});
|
||||
if(p==='/documents/doc-3/editions') return route.fulfill({status:503,json:{error:'Fixture editions error'}});
|
||||
if(p==='/documents/doc-1/editions') { if(delayNextPublicMetadata){delayNextPublicMetadata=false;await new Promise(resolve=>setTimeout(resolve,250));} return route.fulfill({json:{available_languages:['ru','ky'],editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']}]}}); }
|
||||
if(p==='/documents/doc-1/editions/2023') return route.fulfill({json:{content:{ru:{html:'<h2>Russian-only edition</h2><p>Regression fixture.</p>'}}}});
|
||||
if(p==='/documents/doc-1/editions/2024') { if(delayNextEdition){delayNextEdition=false;await new Promise(resolve=>setTimeout(resolve,200));} if(failNextEdition){failNextEdition=false;await new Promise(resolve=>setTimeout(resolve,120));return route.fulfill({status:503,json:{error:'Fixture edition error'}});} return route.fulfill({json:{content:{ru:{html:article},ky:{html:articleKy}}}}); }
|
||||
if(p==='/documents/review-a/editions/2026') { if(delayReviewDocumentA){delayReviewDocumentA=false;await new Promise(resolve=>setTimeout(resolve,350));} return route.fulfill({json:{content:{ru:{html:'<h2>Fixture document A</h2><p>Content for fixture A only.</p>'}}}}); }
|
||||
if(p==='/documents/review-b/editions/2025') { if(failReviewDocumentB) return route.fulfill({status:503,json:{error:'Fixture document B unavailable'}}); return route.fulfill({json:{content:{ru:{html:'<h2>Fixture document B</h2><p>Content for fixture B only.</p>'}}}}); }
|
||||
if(p==='/static/favicon.svg') return route.fulfill({status:200,contentType:'image/svg+xml',body:fs.readFileSync(path.join(root,'../favicon.svg'))});
|
||||
if(p==='/static/favicon-32.png') return route.fulfill({status:200,contentType:'image/png',body:fs.readFileSync(path.join(root,'../favicon-32.png'))});
|
||||
if(p.startsWith('/static/fonts/')) { const f=path.join(root,path.basename(p)); return route.fulfill({status:200,contentType:'font/woff2',body:fs.readFileSync(f)}); }
|
||||
return route.fulfill({status:404,contentType:'application/json',body:'{}'});
|
||||
});
|
||||
await page.goto('https://fixture.invalid/'); await page.locator('#search-results-heading').waitFor();
|
||||
await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36');
|
||||
const freshSplitter=await page.evaluate(()=>{const r=s=>document.querySelector(s).getBoundingClientRect(),tracks=getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ');return {filters:r('#search-filters').width,results:r('.search__results').width,gutter:parseFloat(tracks[2]),document:r('#public-document-panel').width,brand:r('.search__brand').width,descriptor:getComputedStyle(document.querySelector('.search__brand-description')).display,brandLabel:document.querySelector('.search__brand').getAttribute('aria-label')};});
|
||||
await page.setViewportSize({width:1280,height:800});
|
||||
const initialFooterCount=await page.evaluate(()=>[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length); const initialSearch=await page.evaluate(()=>({state:document.querySelector('#public-results').dataset.state,list:document.querySelector('#public-results').textContent.trim(),count:document.querySelector('#result-count').textContent,paginationHidden:document.querySelector('.search__pagination').hidden,breadcrumbHidden:document.querySelector('.search__breadcrumbs').hidden,documentText:document.querySelector('#document-facts').textContent,headingHidden:getComputedStyle(document.querySelector('#public-document-heading')).display==='none',opened:document.querySelectorAll('.search__opened').length}));
|
||||
await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
|
||||
const favicons=await page.evaluate(async()=>Promise.all([...document.querySelectorAll('link[rel="icon"]')].map(async link=>({type:link.type,status:(await fetch(link.href)).status}))));
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
const labNavColor=await page.locator('#search-review-nav').evaluate(el=>getComputedStyle(el).color);
|
||||
await page.locator('.search__result-title').first().click();
|
||||
await page.locator('#public-document-body h2').first().waitFor();
|
||||
const tocState=await page.evaluate(()=>{const root=document.querySelector('#document-toc-list'),links=[...root.querySelectorAll('a')];return {titles:links.map(link=>link.textContent),nested:root.querySelector('ol ol')!==null,targetsUnique:new Set(links.map(link=>link.hash)).size===links.length,anchorsExist:links.every(link=>document.querySelector(link.hash)),ordinaryMentionIncluded:links.some(link=>link.textContent.includes('обычное упоминание')),languageSelectorHidden:document.querySelector('.search__document-language').hidden,splitter:{role:document.querySelector('#search-splitter').getAttribute('role'),orientation:document.querySelector('#search-splitter').getAttribute('aria-orientation'),value:document.querySelector('#search-splitter').getAttribute('aria-valuenow')}};});
|
||||
const safeLinks=await page.evaluate(()=>{const body=document.querySelector('#public-document-body'),official=[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Official')),privateLink=[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Private address'));return {relative:body.querySelector('a[href*=old]')?.getAttribute('href')||null,legacy:[...body.querySelectorAll('a')].find(a=>a.textContent.includes('Legacy'))?.getAttribute('href')||null,official:official?.href||null,ping:official?.getAttribute('ping')||null,privateHref:privateLink?.getAttribute('href')||null,image:body.querySelector('img')?.getAttribute('src')||null,srcset:body.querySelector('img')?.getAttribute('srcset')||null,privateImage:[...body.querySelectorAll('img')].find(image=>image.alt==='Private address image')?.getAttribute('src')||null};});
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
await page.locator('#document-find').fill('налог на прибыль');
|
||||
await page.waitForTimeout(200);
|
||||
await page.addStyleTag({content:'.search__fixture-label{display:inline-block;margin-inline-start:0;padding-inline-start:12px;border-inline-start:1px solid #ded5cd;color:#786469;font-size:13px}' });
|
||||
await page.evaluate(()=>{const n=document.createElement('span');n.className='search__fixture-label';n.textContent='Демонстрационные данные';document.querySelector('#active-filters').after(n);const note=document.querySelector('#document-note');note.textContent='Текст приведён для примера оформления';note.hidden=false});
|
||||
console.log('subnavBoxes',await page.evaluate(()=>[...document.querySelector('.search__subnav').children].map(el=>({className:el.className||el.id,text:el.innerText,rect:(()=>{const r=el.getBoundingClientRect();return {left:r.left,right:r.right,width:r.width}})()})))); const navGeometry=await page.evaluate(()=>{const box=s=>{const r=document.querySelector(s).getBoundingClientRect();return {left:r.left,right:r.right}};const language=document.querySelector('.search__filter-language');const select=language.querySelector('select');const r=language.getBoundingClientRect();const filterHeader=document.querySelector('.search__filters .review__panel-heading').getBoundingClientRect(),filterHeading=document.querySelector('#filters-heading').getBoundingClientRect(),filterReset=document.querySelector('#clear-filters').getBoundingClientRect();return {search:box('#search-nav'),sources:box('#sources-open'),lab:box('#search-review-nav'),labNavVisible:!document.querySelector('#search-review-nav').hidden,filterHeadingSameRow:filterHeading.top<filterReset.bottom&&filterHeading.bottom>filterReset.top,filterLanguageGap:Math.round(language.getBoundingClientRect().top-filterHeader.bottom),splitterWidth:document.querySelector('#search-splitter').getBoundingClientRect().width,filterHeadingSize:parseFloat(getComputedStyle(document.querySelector('#filters-heading')).fontSize),resultsHeadingSize:parseFloat(getComputedStyle(document.querySelector('#search-results-heading')).fontSize),languageInFilters:document.querySelector('#search-filters').contains(language),languageWidth:r.width,languageSelectWidth:select.getBoundingClientRect().width,searchTextAlign:getComputedStyle(document.querySelector('#search-nav')).justifyContent,status:box('#active-filters .search__chip'),account:box('#search-account-open'),accountContentFits:(()=>{const e=document.querySelector('#search-account-open');return e.scrollWidth<=e.clientWidth})()}}); console.log('navGeometry',navGeometry); const documentTitleStyle=await page.locator('#public-document-heading').evaluate(el=>({fontSize:parseFloat(getComputedStyle(el).fontSize),lineHeight:parseFloat(getComputedStyle(el).lineHeight),fontFamily:getComputedStyle(el).fontFamily})); const resultHighlight=await page.locator('.search__result--selected .search__result-snippet mark').first().textContent(); const openedCount=await page.locator('.search__opened').count(); await page.locator('#public-document-panel').evaluate(el=>el.scrollTop=0); await page.evaluate(()=>document.activeElement.blur()); console.log('desktopBoxes', await page.evaluate(()=>Object.fromEntries(['.search__chrome','.search__layout','.search__footer','.search__document'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); console.log('docElements',await page.evaluate(()=>Object.fromEntries(['.search__document-head','#public-document-heading','#public-document-meta','.search__document-toolbar','.search__find','#document-note','#public-document-body h2'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); await page.screenshot({path:path.join(artifacts,'desktop-1280x800.png'),fullPage:false});
|
||||
const splitterDefault=await page.evaluate(()=>{const r=s=>document.querySelector(s).getBoundingClientRect();return {filters:r('#search-filters').width,results:r('.search__results').width,document:r('#public-document-panel').width};}); const splitter=page.locator('#search-splitter'); const splitterStart=Number(await splitter.getAttribute('aria-valuenow')); await splitter.focus(); await page.keyboard.press('ArrowRight'); const splitterKeyboard=Number(await splitter.getAttribute('aria-valuenow')); const keyboardMin=Number(await splitter.getAttribute('aria-valuemin')),keyboardMax=Number(await splitter.getAttribute('aria-valuemax')); await page.keyboard.press('Home'); const splitterHome=Number(await splitter.getAttribute('aria-valuenow')); await page.keyboard.press('End'); const splitterEnd=Number(await splitter.getAttribute('aria-valuenow')); const drag=async x=>{const box=await splitter.boundingBox();await page.mouse.move(box.x+6,box.y+box.height/2);await page.mouse.down();await page.mouse.move(x,box.y+box.height/2,{steps:5});await page.mouse.up();}; await drag(0); const splitterLeft=await page.evaluate(()=>({value:Number(document.querySelector('#search-splitter').getAttribute('aria-valuenow')),filters:document.querySelector('#search-filters').getBoundingClientRect().width,results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); await drag(1280); const splitterRight=await page.evaluate(()=>({value:Number(document.querySelector('#search-splitter').getAttribute('aria-valuenow')),filters:document.querySelector('#search-filters').getBoundingClientRect().width,results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); const splitterPointer=splitterLeft.value; const splitterStored=await page.evaluate(()=>localStorage.getItem('akyldash-search-results-share'));
|
||||
delayNextPublicMetadata=true; const delayedPublicRequest=page.waitForRequest(request=>new URL(request.url()).pathname==='/documents/doc-1/editions'); await page.locator('.search__result-title').first().click(); await delayedPublicRequest; await page.locator('.search__result-title').nth(2).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture editions error'); await page.waitForTimeout(300); const stalePublicSelection=await page.evaluate(()=>({selected:document.querySelector('.search__result--selected').dataset.code,markers:document.querySelectorAll('.search__opened').length})); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); const markerAfterReload=await page.locator('.search__opened').count();
|
||||
await page.evaluate(()=>{localStorage.setItem('akyldash-search-results-share','36');}); await page.reload(); await page.locator('#search-results-heading').waitFor(); await page.locator('.search__result-title').first().waitFor(); const splitterRestored=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),min:Number(el.getAttribute('aria-valuemin')),max:Number(el.getAttribute('aria-valuemax'))})); await page.evaluate(()=>localStorage.setItem('akyldash-search-results-share','55')); await page.reload(); await page.locator('.search__result-title').first().waitFor(); await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36'); const splitterStoredMax=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),max:Number(el.getAttribute('aria-valuemax'))})); await page.setViewportSize({width:1440,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); const splitterResize=await page.evaluate(()=>({results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); await page.setViewportSize({width:1280,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); await page.evaluate(()=>localStorage.setItem('akyldash-search-results-share','25')); await page.reload(); await page.locator('.search__result-title').first().waitFor(); await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36'); const splitterStoredMin=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),min:Number(el.getAttribute('aria-valuemin'))}));
|
||||
const chipsBefore=await page.locator('#active-filters .search__chip').count(); await page.locator('input[name="filter-document_type"][value="code"]').check(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('document_type')==='code'); const chipsWithTwo=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('status')===null); const preservedFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); await page.locator('#active-filters button').first().click(); const urlFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); const firstFindCount=await page.locator('#document-find-count').textContent(); const findClearedAfterFilterChange=!(await page.locator('#document-find-next').isVisible()); await page.locator('input[name="filter-status"][value="active"]').check(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('#document-find').fill('налог на прибыль'); await page.waitForFunction(()=>!document.querySelector('#document-find-next').disabled); await page.locator('#document-find-next').click(); const nextFindCount=await page.locator('#document-find-count').textContent(); await page.locator('#ui-ky').click(); const documentLanguage=await page.locator('#document-language').inputValue(); const kyrgyzUi=await page.evaluate(()=>({htmlLang:document.documentElement.lang,queryLabel:document.querySelector('label[for=public-query]').textContent,filterHeading:document.querySelectorAll('#filter-fields h3')[1].textContent,statusHeading:document.querySelectorAll('#filter-fields h3')[0].textContent,authority:document.querySelector('#filter-authority option').textContent,activeStatus:document.querySelector('#active-filters .search__chip').textContent.trim(),queryPlaceholder:document.querySelector('#public-query').placeholder,resultCount:document.querySelector('#result-count').textContent,findCount:document.querySelector('#document-find-count').textContent,opened:document.querySelector('.search__opened').textContent,statusValues:[...document.querySelectorAll('input[name="filter-status"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`}),documentTypes:[...document.querySelectorAll('input[name="filter-document_type"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`})})); await page.locator('#ui-ru').click(); await page.locator('#document-expand').click(); const expanded=await page.locator('#search-layout').evaluate(e=>e.classList.contains('is-expanded')); await page.keyboard.press('Escape'); const expandedEscapeClosed=await page.locator('#search-layout').evaluate(e=>!e.classList.contains('is-expanded')); const expandedFocusRestored=await page.evaluate(()=>document.activeElement.id); await page.locator('#search-account-open').click(); const accountActions=await page.locator('#account-dialog').evaluate(d=>{const close=d.querySelector('.review__dialog-heading button'),profile=d.querySelector('dl');return {open:d.open,heading:d.querySelector('#account-heading').textContent,reviewVisible:!d.querySelector('#review-nav').hidden,reviewHref:d.querySelector('#review-nav').getAttribute('href'),usersVisible:!d.querySelector('#search-users-open').hidden,usersHref:d.querySelector('#search-users-open').getAttribute('href'),usersLabel:d.querySelector('#search-users-open').textContent,logoutVisible:!d.querySelector('#search-logout').hidden,closeIcon:Boolean(close.querySelector('svg')),closeText:close.textContent.trim(),closeLabel:close.getAttribute('aria-label'),profileGrid:getComputedStyle(profile).display,profileRows:profile.children.length,headingHeight:d.querySelector('.review__dialog-heading').getBoundingClientRect().height,headingFontSize:parseFloat(getComputedStyle(d.querySelector('.review__dialog-heading h2')).fontSize),headingFontFamily:getComputedStyle(d.querySelector('.review__dialog-heading h2')).fontFamily,dataBeforeActions:Boolean(profile.compareDocumentPosition(d.querySelector('.search__account-actions'))&Node.DOCUMENT_POSITION_FOLLOWING),dialogFits:d.scrollWidth<=d.clientWidth};}); const accountGeometry=await page.locator('#account-dialog').evaluate(d=>{const actions=d.querySelector('.search__account-actions'),logout=d.querySelector('#search-logout'),first=actions.querySelector('a').getBoundingClientRect(),out=logout.getBoundingClientRect();return {width:d.getBoundingClientRect().width,logoutSeparate:out.top>=first.bottom,logoutMuted:getComputedStyle(logout).backgroundColor==='rgba(0, 0Line truncated
|
||||
const fonts = await page.evaluate(async()=>({sans:document.fonts.check('400 16px "Noto Sans"'),serif:document.fonts.check('600 24px "Noto Serif"'),kyrgyzSans:(await document.fonts.load('400 16px "Noto Sans"','ӨөҮүҢң')).length>0,kyrgyzSerif:(await document.fonts.load('600 24px "Noto Serif"','ӨөҮүҢң')).length>0,ids:[...document.querySelectorAll('[id]')].map(x=>x.id).filter((id,i,a)=>a.indexOf(id)!==i)}));
|
||||
const widths={}; for(const width of [1440,1280,1024,1713]){await page.setViewportSize({width,height:900});widths[width]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth,layout:document.querySelector('#search-layout').getBoundingClientRect().width,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').map(parseFloat)}));await page.screenshot({path:path.join(artifacts,`desktop-${width}px.png`)});} await page.setViewportSize({width:320,height:800}); widths[320]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth}));
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.reload(); await page.evaluate(()=>document.fonts.ready);
|
||||
const view=await page.evaluate(()=>({mobileView:document.querySelector('#search-layout').dataset.mobileView,scrollWidth:document.documentElement.scrollWidth,width:innerWidth}));
|
||||
await page.locator('#mobile-filters-open').click();
|
||||
const drawer=await page.locator('#search-filters').getAttribute('aria-modal'); const modalBackground=await page.evaluate(()=>({chrome:document.querySelector('.search__chrome').inert,results:document.querySelector('.search__results').inert,document:document.querySelector('.search__document').inert,footer:document.querySelector('.search__footer').inert})); await page.locator('#search-filters').evaluate(el=>{const c=[...el.querySelectorAll('button:not([disabled]),input:not([disabled]),select:not([disabled]),[href]')].filter(n=>n.offsetParent!==null);c.at(-1).focus()}); await page.keyboard.press('Tab'); const focusWrap=await page.evaluate(()=>document.activeElement.id); const focusVisible=await page.evaluate(()=>getComputedStyle(document.activeElement).outlineWidth); await page.keyboard.press('Shift+Tab'); const focusReverseWrap=await page.evaluate(()=>document.activeElement.id);
|
||||
await page.keyboard.press('Escape'); const focusRestored=await page.evaluate(()=>document.activeElement.id);
|
||||
await page.locator('.search__result-title').first().click();
|
||||
await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.screenshot({path:path.join(artifacts,'mobile-390px.png'),fullPage:true});
|
||||
await page.locator('#document-back').click();
|
||||
const back=await page.locator('#search-layout').getAttribute('data-mobile-view'); await page.setViewportSize({width:1024,height:900}); await page.waitForFunction(()=>document.querySelector('#search-filters').getAttribute('aria-hidden')===null); const desktopFilters=await page.evaluate(()=>({visible:getComputedStyle(document.querySelector('#search-filters')).display!=='none',ariaHidden:document.querySelector('#search-filters').getAttribute('aria-hidden')}));
|
||||
await page.setViewportSize({width:1440,height:900}); await page.goto('https://fixture.invalid/review'); await page.locator('#review').waitFor(); const routes={searchHidden:await page.locator('#search-page').isHidden(),reviewVisible:await page.locator('#review').isVisible(),visibleFooters:await page.evaluate(()=>[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length)}; const labLayout=await page.evaluate(()=>{const panel=document.querySelector('#document-panel'),body=document.querySelector('#document-body');for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный текст длинной фиктивной редакции для проверки прокрутки панели документа. '.repeat(4);body.append(p);}const save=document.querySelector('#save'),footer=document.querySelector('body > footer.review__header'),r=panel.getBoundingClientRect();return {initialSaveDisabled:save.disabled,initialSaveText:save.textContent,initialDraft:document.querySelector('#draft-status').textContent,panelHeight:r.height,panelClient:panel.clientHeight,panelScroll:panel.scrollHeight,viewport:innerHeight,navHeight:document.querySelector('body > header.review__header .review__nav').getBoundingClientRect().height,titlebarHeight:document.querySelector('body > header.review__header .review__header-content').getBoundingClientRect().height,actionsHeight:document.querySelector('body > .review__actions').getBoundingClientRect().height,footerHeight:document.querySelector('body > footer.review__header').getBoundingClientRect().height,labTitleSize:parseFloat(getComputedStyle(document.querySelector('#route-heading')).fontSize),sectionHeadingSize:parseFloat(getComputedStyle(document.querySelector('.review__results .review__panel-heading h2')).fontSize),headerLeft:document.querySelector('body > header.review__header').getBoundingClientRect().left,headerRight:document.querySelector('body > header.review__header').getBoundingClientRect().right,workspaceLeft:document.querySelector('.review__workspace').getBoundingClientRect().left,workspaceRight:document.querySelector('.review__workspace').getBoundingClientRect().right,saveAfterPanel:Boolean(panel.compareDocumentPosition(save)&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(save.compareDocumentPosition(footer)&Node.DOCUMENT_POSITION_FOLLOWING),footerVisible:getComputedStyle(footer).display!=='none',headerLeft:document.querySelector('body > header.review__header').getBoundingClientRect().left,headerRight:document.querySelector('body > header.review__header').getBoundingClientRect().right,workspaceLeft:document.querySelector('.review__workspace').getBoundingClientRect().left,workspaceRight:document.querySelector('.review__workspace').getBoundingClientRect().right,publicFooterVisible:getComputedStyle(document.querySelector('.search__footer')).display!=='none'};});
|
||||
const labGeometry=await page.evaluate(()=>{const rect=s=>document.querySelector(s).getBoundingClientRect(),action=rect('body > .review__actions'),workspace=rect('.review__workspace'),footer=rect('body > footer.review__header');return {headingLeft:rect('#route-heading').left,helpRight:rect('#tutorial-open').right,subtitleLeft:rect('#route-description').left,workspaceActionGap:action.top-workspace.bottom,actionFooterGap:footer.top-action.bottom,actionsHeight:action.height,footerHeight:footer.height};});
|
||||
const tutorialScreens={}; for(const [name,viewport] of [['desktop',{width:1280,height:800}],['mobile',{width:390,height:844}]]){await page.setViewportSize(viewport); await page.locator('#tutorial-open').click(); tutorialScreens[name]=[]; for(const target of ['#query','#results','#document-panel','#save']){const selector=name==='mobile'&&target==='#document-panel'?'#document-heading':target;const geometry=await page.evaluate((selector)=>{const dialog=document.querySelector('#tutorial-dialog'),target=document.querySelector(selector),d=dialog.getBoundingClientRect(),t=target.getBoundingClientRect(),style=getComputedStyle(dialog),targetStyle=getComputedStyle(target);return {step:document.querySelector('#tutorial-progress').textContent,width:d.width,height:d.height,overlaps:d.left<t.right&&d.right>t.left&&d.top<t.bottom&&d.bottom>t.top,font:style.fontFamily,titleFont:getComputedStyle(document.querySelector('#tutorial-heading')).fontFamily,background:style.backgroundColor,targetDisplay:targetStyle.display,targetInViewport:t.bottom>0&&t.top<innerHeight,target:{left:t.left,top:t.top,right:t.right,bottom:t.bottom}}},selector);if(geometry.targetDisplay==='none'||geometry.target.right===geometry.target.left||geometry.target.bottom===geometry.target.top||!geometry.targetInViewport) throw new Error(`Tutorial target is not visible at ${name}: ${JSON.stringify(geometry)}`);if(geometry.overlaps) throw new Error(`Tutorial overlaps ${target} at ${name}: ${JSON.stringify(geometry)}`);if(geometry.width>320||!geometry.font.includes('Noto Sans')||!geometry.titleFont.includes('Noto Serif')||!geometry.background.includes('0.92')) throw new Error(`Tutorial style mismatch at ${name}: ${JSON.stringify(geometry)}`);tutorialScreens[name].push(geometry);if(target==='#query') await page.screenshot({path:path.join(artifacts,`review-tour-${name}.png`),fullPage:false});if(target!=='#save') await page.locator('#tutorial-next').press('Enter');}await page.locator('#tutorial-next').press('Enter');}
|
||||
await page.setViewportSize({width:1280,height:800}); await page.locator('#query').fill('налог на прибыль'); delayNextReviewSearch=true; await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#status').classList.contains('review__status--busy')); const labLoadingStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),busy:el.classList.contains('review__status--busy')})); await page.locator('.review__result').first().waitFor(); const successStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),live:el.getAttribute('aria-live'),success:el.classList.contains('review__status--success')})); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); const compactResult=await page.locator('.review__result--selected').evaluate(el=>({padding:getComputedStyle(el).padding,titleTarget:el.querySelector('.review__result-title').getBoundingClientRect().height,ratingTarget:el.querySelector('.review__rating label').getBoundingClientRect().height,metaMargin:getComputedStyle(el.querySelector('.review__meta')).marginBlockStart})); const reviewSaveEnabled=await page.locator('#save').isEnabled(); const commentDisclosure=page.locator('.review__comment-disclosure').first(); const commentStartsCollapsed=!(await commentDisclosure.evaluate(e=>e.open)); await commentDisclosure.locator('summary').click(); await commentDisclosure.locator('textarea').fill('Фиктивный комментарий для проверки черновика'); await page.locator('input[name="rating-0"][value="2"]').check(); await page.waitForFunction(()=>document.querySelector('#draft-status').textContent==='Черновик сохранён на этом устройстве.'); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); await page.locator('#document-body').evaluate(body=>{for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный длинный документ в безопасном browser fixture. '.repeat(5);body.append(p);}}); await page.setViewportSize({width:1280,height:720}); const reviewScroll=await page.locator('#document-panel').evaluate(panel=>({height:panel.getBoundingClientRect().height,client:panel.clientHeight,scroll:panel.scrollHeight,viewport:innerHeight,internalScroll:panel.scrollHeight>panel.clientHeight,saveAfter:Boolean(panel.compareDocumentPosition(document.querySelector('#save'))&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(document.querySelector('#save').compareDocumentPosition(document.querySelector('body > footer.review__header'))&Node.DOCUMENT_POSITION_FOLLOWING)})); await page.locator('#save').scrollIntoViewIfNeeded(); const saveViewport=await page.locator('#save').evaluate(el=>{const r=el.getBoundingClientRect();const footer=document.querySelector('body > footer.review__header').getBoundingClientRect();return {top:r.top,bottom:r.bottom,visible:r.top>=0&&r.bottom<=innerHeight,footerVisible:footer.top>=0&&footer.bottom<=innerHeight}}); await page.screenshot({path:path.join(artifacts,'lab-save-footer-1280x720.png'),fullPage:false}); await page.locator('#save').click(); await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена')); const reviewFlow={reviewSaveEnabled,commentStartsCollapsed,commentExpanded:await commentDisclosure.evaluate(e=>e.open),draftStatus:await page.locator('#draft-status').textContent(),reviewScroll,saveViewport,reviewSaved:await page.locator('#status').textContent()};
|
||||
await page.locator('#query').fill('document-switch-fixture'); await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('.review__result-title span:last-child')?.textContent==='Fixture document A');
|
||||
await page.locator('.review__comment-disclosure').first().locator('summary').click(); await page.locator('.review__comment').first().fill('Fixture-only note for result A.'); const fixtureRating=page.locator('input[name="rating-0"][value="2"]'); await fixtureRating.check(); const ratingBeforeDocumentSwitch=await fixtureRating.isChecked();
|
||||
await page.locator('.review__result-title').nth(0).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document A'}).waitFor();
|
||||
failReviewDocumentB=true; await page.locator('.review__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#status').textContent==='Не удалось загрузить документ. Повторите попытку.'); const errorStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),live:el.getAttribute('aria-live')}));
|
||||
const failedDocumentB=await page.evaluate(()=>({selected:document.querySelector('.review__result--selected .review__result-title span:last-child').textContent,heading:document.querySelector('#dialog-heading').textContent,body:document.querySelector('#document-body').textContent,dialogBody:document.querySelector('#dialog-body').textContent,sourceCount:document.querySelectorAll('#document-meta a').length,rating:document.querySelector('input[name="rating-0"]:checked')?.value||null,comment:document.querySelector('.review__comment').value}));
|
||||
failReviewDocumentB=false; await page.locator('.review__result-title').nth(1).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document B'}).waitFor();
|
||||
const successfulDocumentB=await page.evaluate(()=>({heading:document.querySelector('#dialog-heading').textContent,body:document.querySelector('#document-body').textContent,source:document.querySelector('#document-meta a')?.href||null,rating:document.querySelector('input[name="rating-0"]:checked')?.value||null,comment:document.querySelector('.review__comment').value}));
|
||||
delayReviewDocumentA=true; const staleRequest=page.waitForRequest((request)=>new URL(request.url()).pathname==='/documents/review-a/editions/2026'); await page.locator('.review__result-title').nth(0).click(); await staleRequest; await page.locator('.review__result-title').nth(1).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document B'}).waitFor(); await page.waitForTimeout(400);
|
||||
const staleDocumentA=await page.evaluate(()=>({heading:document.querySelector('#dialog-heading').textContent,body:document.querySelector('#document-body').textContent,source:document.querySelector('#document-meta a')?.href||null,selected:document.querySelector('.review__result--selected .review__result-title span:last-child').textContent}));
|
||||
await page.locator('#save').click(); await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена'));
|
||||
const documentSwitch={ratingBeforeDocumentSwitch,failedDocumentB,successfulDocumentB,staleDocumentA,lastReviewRatings:fixtureState.lastReviewRatings,reviewSaves:fixtureState.reviewSaves}; console.log('documentSwitchFixture',JSON.stringify(documentSwitch));
|
||||
if(!ratingBeforeDocumentSwitch||failedDocumentB.selected!=='Fixture document B'||failedDocumentB.heading!=='Fixture document B'||failedDocumentB.body||failedDocumentB.dialogBody||failedDocumentB.sourceCount||failedDocumentB.rating!=='2'||failedDocumentB.comment!=='Fixture-only note for result A.'||successfulDocumentB.heading!=='Fixture document B'||!successfulDocumentB.body.includes('Content for fixture B only.')||!successfulDocumentB.source.includes('/review-b/edition/2025/ru')||successfulDocumentB.rating!=='2'||successfulDocumentB.comment!=='Fixture-only note for result A.'||staleDocumentA.heading!=='Fixture document B'||!staleDocumentA.body.includes('Content for fixture B only.')||!staleDocumentA.source.includes('/review-b/edition/2025/ru')||staleDocumentA.selected!=='Fixture document B'||!fixtureState.lastReviewRatings.some((item)=>item.code==='review-a'&&item.rating===2)||fixtureState.reviewSaves<2) process.exit(1);
|
||||
await page.setViewportSize({width:1280,height:800}); await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
|
||||
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('#document-language').selectOption('ky'); await page.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const languageFallback={language:await page.locator('#document-language').inputValue(),heading:await page.locator('#public-document-body h2').textContent(),selectorHidden:await page.locator('.search__document-language').evaluate(label=>label.hidden)};
|
||||
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); const noEditionExpandedText=await page.locator('#public-document-body').textContent(); const noEditionExpandedHeading=await page.locator('#public-document-heading').textContent(); const noEditionExpandHidden=await page.locator('#document-expand').evaluate(e=>getComputedStyle(e).display==='none');
|
||||
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('.search__result-title').nth(2).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture editions error'); const editionErrorExpandedText=await page.locator('#public-document-body').textContent(); const editionErrorExpandedHeading=await page.locator('#public-document-heading').textContent(); const editionErrorExpandHidden=await page.locator('#document-expand').evaluate(e=>getComputedStyle(e).display==='none');
|
||||
await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
|
||||
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.locator('#sources-open').click(); const sourcesDialog=await page.evaluate(()=>({open:document.querySelector('#sources-dialog').open,labelledBy:document.querySelector('#sources-dialog').getAttribute('aria-labelledby'),focusInside:document.querySelector('#sources-dialog').contains(document.activeElement)})); await page.keyboard.press('Escape'); const sourcesEscapeClosed=await page.locator('#sources-dialog').evaluate(d=>!d.open); const sourcesFocusRestored=await page.evaluate(()=>document.activeElement.id);
|
||||
await page.locator('#search-account-open').click(); const accountDialogFocusInside=await page.locator('#account-dialog').evaluate(d=>d.contains(document.activeElement)); await page.keyboard.press('Escape'); const accountEscapeClosed=await page.locator('#account-dialog').evaluate(d=>!d.open); const accountFocusRestored=await page.evaluate(()=>document.activeElement.id);
|
||||
await page.emulateMedia({media:'print'}); const printState=await page.evaluate(()=>({chrome:getComputedStyle(document.querySelector('.search__chrome')).display,filters:getComputedStyle(document.querySelector('.search__filters')).display,results:getComputedStyle(document.querySelector('.search__results')).display,footer:getComputedStyle(document.querySelector('.search__footer')).display,document:getComputedStyle(document.querySelector('#public-document-panel')).display,source:getComputedStyle(document.querySelector('#document-source')).display,sourceHidden:document.querySelector('#document-source').hidden})); await page.emulateMedia({media:'screen'});
|
||||
const tableOverflow=await page.locator('#public-document-body').evaluate(body=>{const table=document.createElement('table');table.style.width='100%';table.innerHTML='<tbody><tr>'+Array.from({length:10},()=>'<td>'+('широкаятаблица'.repeat(20))+'</td>').join('')+'</tr></tbody>';body.append(table);const result={clientWidth:table.clientWidth,scrollWidth:table.scrollWidth,pageWidth:document.documentElement.scrollWidth,viewport:innerWidth};table.remove();return result});
|
||||
await page.emulateMedia({reducedMotion:'reduce'}); const reducedMotion=await page.evaluate(()=>({scrollBehavior:getComputedStyle(document.documentElement).scrollBehavior,transitionDuration:getComputedStyle(document.querySelector('#document-print')).transitionDuration})); await page.emulateMedia({reducedMotion:'no-preference'});
|
||||
await page.locator('#public-query').fill('service-error'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#public-status').textContent==='Fixture search error'); const searchError={message:await page.locator('#public-status').textContent(),query:await page.locator('#public-query').inputValue(),role:await page.locator('#public-status').getAttribute('role')}; await page.locator('#public-query').fill('налог на прибыль'); await page.locator('#public-search-form button[type=submit]').click(); await page.locator('.search__result-title').first().waitFor(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.locator('#document-edition').selectOption('2023'); await page.waitForFunction(()=>document.querySelector('#public-document-body h2')?.textContent==='Russian-only edition'); const previousEditionSource=await page.locator('#document-source').getAttribute('href'); failNextEdition=true; await page.locator('#document-edition').selectOption('2024'); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const sourceDuringEditionLoad=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href'),opened:document.querySelectorAll('.search__opened').length})); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture edition error'); const editionError=await page.locator('#document-facts').textContent(); const sourceAfterEditionError=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href'),opened:document.querySelectorAll('.search__opened').length})); await page.locator('#document-edition').selectOption('2024'); await page.locator('#public-document-body h2').first().waitFor(); const editionRetry=await page.locator('#public-document-body h2').first().textContent(); const sourceAfterEditionRetry=await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href'),opened:document.querySelectorAll('.search__opened').length}));
|
||||
await page.locator('.search__result-title').nth(1).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Для документа нет доступных редакций с текстом.'); const noEditions={message:await page.locator('#document-facts').textContent(),bodyChildren:await page.locator('#public-document-body').locator(':scope > *').count(),source:await page.locator('#document-source').evaluate(link=>({hidden:link.hidden,href:link.getAttribute('href')})),languageSelectorHidden:await page.locator('.search__document-language').evaluate(label=>label.hidden)};
|
||||
delayNextEdition=true; await page.locator('.search__result-title').first().click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Загрузка редакции…'); const editionLoading=true; await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.setViewportSize({width:1280,height:800});
|
||||
await page.locator('#search-account-open').click(); await page.evaluate(()=>{const d=document.querySelector('#account-dialog');const controls=[...d.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e=>e.getClientRects().length&&getComputedStyle(e).visibility!=='hidden');controls.at(-1).focus();}); await page.keyboard.press('Tab'); const accountTabTarget=await page.evaluate(()=>({tag:document.activeElement.tagName,id:document.activeElement.id,dialog:document.querySelector('#account-dialog').contains(document.activeElement)})); const accountTabWrap=accountTabTarget.dialog; await page.keyboard.press('Shift+Tab'); const accountReverseTabWrap=await page.evaluate(()=>({id:document.activeElement.id,dialog:document.querySelector('#account-dialog').contains(document.activeElement)})); await page.keyboard.press('Escape');
|
||||
const viewportWidths=[]; for (const width of [320,390,640,1024,1101,1175,1176,1280,1440,1713]) { await page.setViewportSize({width,height:800}); viewportWidths.push(await page.evaluate(()=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth}))); } await page.setViewportSize({width:1280,height:800});
|
||||
const breakpointLayouts=[]; for (const width of [1024,1101,1175,1191]) { await page.setViewportSize({width,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); breakpointLayouts.push(await page.evaluate(()=>{const rect=(selector)=>document.querySelector(selector).getBoundingClientRect();return {width:innerWidth,scrollWidth:document.documentElement.scrollWidth,filtersWidth:rect('#search-filters').width,resultsWidth:rect('.search__results').width,splitterWidth:rect('#search-splitter').width,documentWidth:rect('#public-document-panel').width,filtersOpen:document.querySelector('#search-filters').classList.contains('is-open')}})); }
|
||||
await page.setViewportSize({width:1024,height:800}); await page.locator('#mobile-filters-open').click(); const filterDrawer=await page.evaluate(()=>({open:document.querySelector('#search-filters').classList.contains('is-open'),modal:document.querySelector('#search-filters').getAttribute('aria-modal'),overlay:getComputedStyle(document.querySelector('#filter-overlay')).display!=='none',results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); const filterCloseVisible=await page.locator('#mobile-filters-close').isVisible(); await page.locator('#mobile-filters-close').click(); await page.locator('#mobile-filters-open').click(); await page.keyboard.press('Escape'); const filterDrawerEscape=await page.evaluate(()=>({closed:!document.querySelector('#search-filters').classList.contains('is-open'),focus:document.activeElement.id})); await page.setViewportSize({width:1280,height:800});
|
||||
const longContent=await page.evaluate(()=>{const result=document.querySelector('.search__result-title'),heading=document.querySelector('#public-document-heading'),oldResult=result.textContent,oldHeading=heading.textContent;const longName='Кыргызская Республика '.repeat(8);result.textContent=longName;heading.textContent=longName;const image=document.createElement('img');image.width=2200;image.height=100;image.src='data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="2200" height="100"><rect width="2200" height="100" fill="gold"/></svg>');document.querySelector('#public-document-body').append(image);const titleWidths=[result,heading].map(el=>({client:el.clientWidth,scroll:el.scrollWidth}));const imageWidth=image.getBoundingClientRect().width,bodyWidth=document.querySelector('#public-document-body').clientWidth,pageWidth=document.documentElement.scrollWidth;result.textContent=oldResult;heading.textContent=oldHeading;image.remove();return {titleWidths,imageWidth,bodyWidth,pageWidth,viewport:innerWidth}});
|
||||
console.log('safeLinks',JSON.stringify(safeLinks)); console.log('initialSearch',JSON.stringify(initialSearch)); console.log('tocState',JSON.stringify(tocState)); console.log('splitterState',JSON.stringify({splitterStart,splitterKeyboard,keyboardMin,keyboardMax,splitterHome,splitterEnd,splitterLeft,splitterRight,splitterPointer,splitterStored,splitterRestored,splitterStoredMax,splitterStoredMin,splitterResize,stalePublicSelection,markerAfterReload})); console.log('freshSplitter',JSON.stringify(freshSplitter)); console.log('splitterDefault',JSON.stringify(splitterDefault)); console.log('filterDrawer',JSON.stringify({filterDrawer,filterCloseVisible,filterDrawerEscape})); console.log('visualDetails',JSON.stringify({successStatus,errorStatus,labLoadingStatus,compactResult,accountGeometry})); console.log('viewportWidths',JSON.stringify(viewportWidths)); console.log('breakpointLayouts',JSON.stringify(breakpointLayouts));
|
||||
console.log('subnavAcceptance',JSON.stringify({sourcesDialog,sourcesEscapeClosed,sourcesFocusRestored,accountDialogFocusInside,accountEscapeClosed,accountFocusRestored,printState,tableOverflow,reducedMotion,searchError,editionError,editionRetry,previousEditionSource,sourceDuringEditionLoad,sourceAfterEditionError,sourceAfterEditionRetry,noEditions,languageFallback,noEditionExpandedText,noEditionExpandedHeading,noEditionExpandHidden,editionErrorExpandedText,editionErrorExpandedHeading,editionErrorExpandHidden,expandedEscapeClosed,expandedFocusRestored,editionLoading,accountTabWrap,accountTabTarget,accountReverseTabWrap,longContent,tutorialScreens,viewportWidths,breakpointLayouts}));
|
||||
console.log(JSON.stringify({resultHighlight,fonts,favicons,widths,view,drawer,modalBackground,focusWrap,focusVisible,focusReverseWrap,focusRestored,back,chipsBefore,chipsWithTwo,preservedFilters,urlFilters,openedCount,firstFindCount,findClearedAfterFilterChange,nextFindCount,documentTitleStyle,documentLanguage,kyrgyzUi,kyrgyzEmpty,expanded,accountActions,desktopFilters,routes,errors},null,2));
|
||||
const referencePath=process.env.REFERENCE_IMAGE || path.join(repo, 'docs/design/tunduk-search-target.png');
|
||||
if(fs.existsSync(referencePath)) {
|
||||
const comparePage=await browser.newPage();
|
||||
const reference=fs.readFileSync(referencePath).toString('base64'), actual=fs.readFileSync(path.join(artifacts,'desktop-1280x800.png')).toString('base64');
|
||||
await comparePage.setContent(`<canvas id="overlay" width="1280" height="800"></canvas><canvas id="difference" width="1280" height="800"></canvas><img id="reference" src="data:image/png;base64,${reference}"><img id="actual" src="data:image/png;base64,${actual}">`);
|
||||
await comparePage.evaluate(()=>Promise.all([...document.images].map((image)=>image.decode())));
|
||||
const comparison=await comparePage.evaluate(()=>{
|
||||
const ref=document.querySelector('#reference'),act=document.querySelector('#actual');
|
||||
const place=(context,image)=>{const scale=Math.min(1280/image.naturalWidth,800/image.naturalHeight),width=image.naturalWidth*scale,height=image.naturalHeight*scale;context.fillStyle='#F7F2E8';context.fillRect(0,0,1280,800);context.drawImage(image,(1280-width)/2,(800-height)/2,width,height)};
|
||||
const overlay=document.querySelector('#overlay'),o=overlay.getContext('2d');place(o,ref);o.globalAlpha=.5;o.drawImage(act,0,0,1280,800);o.globalAlpha=1;
|
||||
const difference=document.querySelector('#difference'),d=difference.getContext('2d');place(d,ref);
|
||||
const actualCanvas=document.createElement('canvas');actualCanvas.width=1280;actualCanvas.height=800;actualCanvas.getContext('2d').drawImage(act,0,0,1280,800);
|
||||
const a=d.getImageData(0,0,1280,800),b=actualCanvas.getContext('2d').getImageData(0,0,1280,800);
|
||||
for(let i=0;i<a.data.length;i+=4){const v=Math.max(Math.abs(a.data[i]-b.data[i]),Math.abs(a.data[i+1]-b.data[i+1]),Math.abs(a.data[i+2]-b.data[i+2]));a.data[i]=v;a.data[i+1]=0;a.data[i+2]=0;a.data[i+3]=255}d.putImageData(a,0,0);
|
||||
const crop=(source,[x,y,width,height])=>{const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;canvas.getContext('2d').drawImage(source,x,y,width,height,0,0,width,height);return canvas.toDataURL('image/png')};
|
||||
const regions={header:[0,0,1280,127],filters:[0,127,226,617],results:[226,127,422,617],document:[648,127,632,617],footer:[0,744,1280,56]};
|
||||
return {overlay:overlay.toDataURL('image/png'),difference:difference.toDataURL('image/png'),regions:Object.entries(regions).map(([name,rect])=>({name,overlay:crop(overlay,rect),difference:crop(difference,rect)}))};
|
||||
});
|
||||
fs.writeFileSync(path.join(artifacts,'overlay-50.png'),Buffer.from(comparison.overlay.split(',')[1],'base64'));
|
||||
fs.writeFileSync(path.join(artifacts,'pixel-difference.png'),Buffer.from(comparison.difference.split(',')[1],'base64'));
|
||||
for(const region of comparison.regions){fs.writeFileSync(path.join(artifacts,`region-${region.name}-overlay.png`),Buffer.from(region.overlay.split(',')[1],'base64'));fs.writeFileSync(path.join(artifacts,`region-${region.name}-difference.png`),Buffer.from(region.difference.split(',')[1],'base64'));}
|
||||
await comparePage.close();
|
||||
}
|
||||
const failedCoreChecks = [
|
||||
["safeLinks.relative!==null", safeLinks.relative!==null],
|
||||
["safeLinks.legacy!==null", safeLinks.legacy!==null],
|
||||
["safeLinks.official!=='https://cbd.minjust.gov.kg/doc-1'", safeLinks.official!=='https://cbd.minjust.gov.kg/doc-1'],
|
||||
["safeLinks.ping!==null", safeLinks.ping!==null],
|
||||
["safeLinks.privateHref!==null", safeLinks.privateHref!==null],
|
||||
["safeLinks.image!==null", safeLinks.image!==null],
|
||||
["safeLinks.srcset!==null", safeLinks.srcset!==null],
|
||||
["safeLinks.privateImage!==null", safeLinks.privateImage!==null],
|
||||
["viewportWidths.some(item=>item.scrollWidth>item.width)", viewportWidths.some(item=>item.scrollWidth>item.width)],
|
||||
["breakpointLayouts.some(item=>item.scrollWidth>item.width)", breakpointLayouts.some(item=>item.scrollWidth>item.width)],
|
||||
["compact filters stay behind the drawer while both panels fit", breakpointLayouts.some(item=>item.filtersWidth!==0||item.resultsWidth<360||item.splitterWidth!==0||item.documentWidth<560)],
|
||||
["compact filter drawer preserves access without replacing the two content panels", !filterDrawer.open||filterDrawer.modal!=="true"||!filterDrawer.overlay||filterDrawer.results<360||filterDrawer.document<560||!filterCloseVisible||!filterDrawerEscape.closed||filterDrawerEscape.focus!=='mobile-filters-open'],
|
||||
["resultHighlight!=='\u043d\u0430\u043b\u043e\u0433\u0435 \u043d\u0430 \u043f\u0440\u0438\u0431\u044b\u043b\u044c'", resultHighlight!=='налоге на прибыль'],
|
||||
["initialSearch.state!=='initial'", initialSearch.state!=='initial'],
|
||||
["initialSearch.count!==''", initialSearch.count!=='' ],
|
||||
["!initialSearch.paginationHidden", !initialSearch.paginationHidden],
|
||||
["!initialSearch.breadcrumbHidden", !initialSearch.breadcrumbHidden],
|
||||
["initialSearch.opened!==0", initialSearch.opened!==0],
|
||||
["!initialSearch.headingHidden", !initialSearch.headingHidden],
|
||||
["!tocState.nested", !tocState.nested],
|
||||
["documentTitleStyle.fontSize!==30", documentTitleStyle.fontSize!==30],
|
||||
["documentTitleStyle.lineHeight!==38", documentTitleStyle.lineHeight!==38],
|
||||
["!documentTitleStyle.fontFamily.includes('Noto Serif')", !documentTitleStyle.fontFamily.includes('Noto Serif')],
|
||||
["!tocState.targetsUnique", !tocState.targetsUnique],
|
||||
["!tocState.anchorsExist", !tocState.anchorsExist],
|
||||
["tocState.ordinaryMentionIncluded", tocState.ordinaryMentionIncluded],
|
||||
["tocState.languageSelectorHidden", tocState.languageSelectorHidden],
|
||||
["tocState.splitter.role!=='separator'", tocState.splitter.role!=='separator'],
|
||||
["navGeometry.languageInFilters!==true", navGeometry.languageInFilters!==true],
|
||||
["!navGeometry.labNavVisible", !navGeometry.labNavVisible],
|
||||
["labNavColor!=='rgb(157, 38, 51)'", labNavColor!=='rgb(157, 38, 51)'],
|
||||
["!navGeometry.filterHeadingSameRow", !navGeometry.filterHeadingSameRow],
|
||||
["navGeometry.filterLanguageGap>10", navGeometry.filterLanguageGap>10],
|
||||
["navGeometry.splitterWidth!==12", navGeometry.splitterWidth!==12],
|
||||
["navGeometry.filterHeadingSize!==20", navGeometry.filterHeadingSize!==20],
|
||||
["navGeometry.resultsHeadingSize!==20", navGeometry.resultsHeadingSize!==20],
|
||||
["navGeometry.languageSelectWidth<120", navGeometry.languageSelectWidth<120],
|
||||
["navGeometry.searchTextAlign!=='center'", navGeometry.searchTextAlign!=='center'],
|
||||
["splitterKeyboard<=splitterStart", splitterKeyboard<=splitterStart],
|
||||
["splitterDefault.filters!==256||Math.abs(splitterDefault.results-384)>1||Math.abs(splitterDefault.document-624)>1", splitterDefault.filters!==256||Math.abs(splitterDefault.results-384)>1||Math.abs(splitterDefault.document-624)>1],
|
||||
["successStatus.height>1||successStatus.role!=='status'||successStatus.live!=='polite'||!successStatus.success", successStatus.height>1||successStatus.role!=='status'||successStatus.live!=='polite'||!successStatus.success],
|
||||
["errorStatus.height<=1||errorStatus.role!=='alert'||errorStatus.live!=='assertive'", errorStatus.height<=1||errorStatus.role!=='alert'||errorStatus.live!=='assertive'],
|
||||
["labLoadingStatus.height<=1||labLoadingStatus.role!=='status'||!labLoadingStatus.busy", labLoadingStatus.height<=1||labLoadingStatus.role!=='status'||!labLoadingStatus.busy],
|
||||
["compactResult.padding!=='8px 10px'||compactResult.titleTarget<44||compactResult.ratingTarget<44", compactResult.padding!=='8px 10px'||compactResult.titleTarget<44||compactResult.ratingTarget<44],
|
||||
["accountGeometry.width!==560||!accountGeometry.logoutSeparate||!accountGeometry.logoutMuted", accountGeometry.width!==560||!accountGeometry.logoutSeparate||!accountGeometry.logoutMuted],
|
||||
["splitterHome!==keyboardMin", splitterHome!==keyboardMin],
|
||||
["splitterEnd!==keyboardMax", splitterEnd!==keyboardMax],
|
||||
["splitterPointer<25||splitterPointer>55", splitterPointer<25||splitterPointer>55],
|
||||
["splitterLeft.results<360||splitterLeft.document<560||splitterLeft.filters<256", splitterLeft.results<360||splitterLeft.document<560||splitterLeft.filters<256],
|
||||
["splitterRight.results<360||splitterRight.document<560||splitterRight.filters<256", splitterRight.results<360||splitterRight.document<560||splitterRight.filters<256],
|
||||
["Number(splitterStored)!==splitterRight.value", Number(splitterStored)!==splitterRight.value],
|
||||
["splitterRestored.value<splitterRestored.min||splitterRestored.value>splitterRestored.max", splitterRestored.value<splitterRestored.min||splitterRestored.value>splitterRestored.max],
|
||||
["splitterStoredMax.value!==splitterStoredMax.max||splitterStoredMin.value!==splitterStoredMin.min", splitterStoredMax.value!==splitterStoredMax.max||splitterStoredMin.value!==splitterStoredMin.min],
|
||||
["splitterResize.results<360||splitterResize.document<560", splitterResize.results<360||splitterResize.document<560],
|
||||
["freshSplitter.filters!==280||Math.abs(freshSplitter.results-408)>2||freshSplitter.gutter!==8||freshSplitter.document<560||freshSplitter.brand!==166||freshSplitter.descriptor!=='none'||freshSplitter.brandLabel!=='Акылдаш — поиск по правовой базе'", freshSplitter.filters!==280||Math.abs(freshSplitter.results-408)>2||freshSplitter.gutter!==8||freshSplitter.document<560||freshSplitter.brand!==166||freshSplitter.descriptor!=='none'||freshSplitter.brandLabel!=='Акылдаш — поиск по правовой базе'],
|
||||
["stalePublicSelection.selected!=='doc-3'", stalePublicSelection.selected!=='doc-3'],
|
||||
["stalePublicSelection.markers!==0", stalePublicSelection.markers!==0],
|
||||
["markerAfterReload!==1", markerAfterReload!==1],
|
||||
["errors.length", errors.length],
|
||||
["!fonts.sans", !fonts.sans],
|
||||
["!fonts.serif", !fonts.serif],
|
||||
["!fonts.kyrgyzSans", !fonts.kyrgyzSans],
|
||||
["!fonts.kyrgyzSerif", !fonts.kyrgyzSerif],
|
||||
["fonts.ids.length", fonts.ids.length],
|
||||
["favicons.length!==2", favicons.length!==2],
|
||||
["favicons.some(item=>item.status!==200)", favicons.some(item=>item.status!==200)],
|
||||
["Object.values(widths).some(x=>x.scrollWidth>x.viewport)", Object.values(widths).some(x=>x.scrollWidth>x.viewport)],
|
||||
["view.scrollWidth>view.width", view.scrollWidth>view.width],
|
||||
["drawer!=='true'", drawer!=='true'],
|
||||
["Object.values(modalBackground).some(value=>value!==true)", Object.values(modalBackground).some(value=>value!==true)],
|
||||
["focusWrap!=='clear-filters'", focusWrap!=='clear-filters'],
|
||||
["focusVisible!=='3px'", focusVisible!=='3px'],
|
||||
["focusReverseWrap!=='date-to'", focusReverseWrap!=='date-to'],
|
||||
["focusRestored!=='mobile-filters-open'", focusRestored!=='mobile-filters-open'],
|
||||
["back!=='results'", back!=='results'],
|
||||
["openedCount!==1", openedCount!==1],
|
||||
["chipsBefore!==1", chipsBefore!==1],
|
||||
["chipsWithTwo!==2", chipsWithTwo!==2],
|
||||
["preservedFilters.status!==null", preservedFilters.status!==null],
|
||||
["preservedFilters.documentType!=='code'", preservedFilters.documentType!=='code'],
|
||||
["urlFilters.status!==null", urlFilters.status!==null],
|
||||
["urlFilters.documentType!==null", urlFilters.documentType!==null],
|
||||
["firstFindCount!==''", firstFindCount!==''],
|
||||
["!findClearedAfterFilterChange", !findClearedAfterFilterChange],
|
||||
["nextFindCount!=='2 \u0438\u0437 3'", nextFindCount!=='2 из 3'],
|
||||
["documentLanguage!=='ru'", documentLanguage!=='ru'],
|
||||
["kyrgyzUi.htmlLang!=='ky'", kyrgyzUi.htmlLang!=='ky'],
|
||||
["kyrgyzUi.queryLabel!=='\u0423\u043a\u0443\u043a\u0442\u0443\u043a \u0431\u0430\u0437\u0430 \u0431\u043e\u044e\u043d\u0447\u0430 \u0438\u0437\u0434\u04e9\u04e9'", kyrgyzUi.queryLabel!=='Укуктук база боюнча издөө'],
|
||||
["kyrgyzUi.statusHeading!=='\u0421\u0442\u0430\u0442\u0443\u0441'", kyrgyzUi.statusHeading!=='Статус'],
|
||||
["kyrgyzUi.filterHeading!=='\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0438\u043d \u0442\u04af\u0440\u04af'", kyrgyzUi.filterHeading!=='Документтин түрү'],
|
||||
["kyrgyzUi.authority!=='\u0411\u0430\u0440\u0434\u044b\u043a \u043e\u0440\u0433\u0430\u043d\u0434\u0430\u0440'", kyrgyzUi.authority!=='Бардык органдар'],
|
||||
["kyrgyzUi.activeStatus!=='\u041a\u04af\u0447\u04af\u043d\u0434\u04e9'", kyrgyzUi.activeStatus!=='Күчүндө'],
|
||||
["JSON.stringify(kyrgyzUi.statusValues)!==JSON.stringify(['\u041a\u04af\u0447\u04af\u043d\u0434\u04e9 (24)','\u041a\u04af\u0447\u04af\u043d \u0436\u043e\u0433\u043e\u0442\u0442\u0443 (5)'])", JSON.stringify(kyrgyzUi.statusValues)!==JSON.stringify(['Күчүндө (24)','Күчүн жоготту (5)'])],
|
||||
["JSON.stringify(kyrgyzUi.documentTypes)!==JSON.stringify(['\u041a\u043e\u0434\u0435\u043a\u0441 (12)','\u041c\u044b\u0439\u0437\u0430\u043c (10)','\u0422\u043e\u043a\u0442\u043e\u043c (8)'])", JSON.stringify(kyrgyzUi.documentTypes)!==JSON.stringify(['Кодекс (12)','Мыйзам (10)','Токтом (8)'])],
|
||||
["kyrgyzUi.resultCount!=='3 \u043d\u0430\u0442\u044b\u0439\u0436\u0430 \u0431\u0435\u0442\u0442\u0435'", kyrgyzUi.resultCount!=='3 натыйжа бетте'],
|
||||
["kyrgyzUi.findCount!=='2 \u0438\u0447\u0438\u043d\u0435\u043d 3'", kyrgyzUi.findCount!=='2 ичинен 3'],
|
||||
["kyrgyzUi.opened!=='\u0410\u0447\u044b\u043b\u0434\u044b'", kyrgyzUi.opened!=='Ачылды'],
|
||||
["kyrgyzEmpty.count!=='0 \u043d\u0430\u0442\u044b\u0439\u0436\u0430 \u0431\u0435\u0442\u0442\u0435'", kyrgyzEmpty.count!=='0 натыйжа бетте'],
|
||||
["kyrgyzEmpty.message!=='\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0435\u0440 \u0442\u0430\u0431\u044b\u043b\u0433\u0430\u043d \u0436\u043e\u043a'", kyrgyzEmpty.message!=='Документтер табылган жок'],
|
||||
["!expanded", !expanded],
|
||||
["!expandedEscapeClosed", !expandedEscapeClosed],
|
||||
["expandedFocusRestored!=='document-expand'", expandedFocusRestored!=='document-expand'],
|
||||
["!accountActions.open", !accountActions.open],
|
||||
["!accountActions.heading", !accountActions.heading],
|
||||
["accountActions.reviewVisible", accountActions.reviewVisible],
|
||||
["accountActions.reviewHref!=='/review'", accountActions.reviewHref!=='/review'],
|
||||
["!accountActions.usersVisible", !accountActions.usersVisible],
|
||||
["accountActions.usersHref!=='/users'", accountActions.usersHref!=='/users'],
|
||||
["!accountActions.usersLabel.includes('Управление пользователями')", !accountActions.usersLabel.includes('Управление пользователями')],
|
||||
["!accountActions.logoutVisible", !accountActions.logoutVisible],
|
||||
["!accountActions.closeIcon", !accountActions.closeIcon],
|
||||
["accountActions.closeText!==''", accountActions.closeText!=='' ],
|
||||
["accountActions.closeLabel!=='Закрыть'", accountActions.closeLabel!=='Закрыть'],
|
||||
["accountActions.profileGrid!=='grid'", accountActions.profileGrid!=='grid'],
|
||||
["accountActions.profileRows!==6", accountActions.profileRows!==6],
|
||||
["accountActions.headingHeight!==44", accountActions.headingHeight!==44],
|
||||
["accountActions.headingFontSize!==20", accountActions.headingFontSize!==20],
|
||||
["!accountActions.headingFontFamily.includes('Noto Sans')", !accountActions.headingFontFamily.includes('Noto Sans')],
|
||||
["!accountActions.dataBeforeActions", !accountActions.dataBeforeActions],
|
||||
["!accountActions.dialogFits", !accountActions.dialogFits],
|
||||
["desktopFilters.visible!==false", desktopFilters.visible!==false],
|
||||
["desktopFilters.ariaHidden!==null", desktopFilters.ariaHidden!==null],
|
||||
["routes.searchHidden!==true", routes.searchHidden!==true],
|
||||
["routes.reviewVisible!==true", routes.reviewVisible!==true],
|
||||
["initialFooterCount!==1||routes.visibleFooters!==1", initialFooterCount!==1||routes.visibleFooters!==1],
|
||||
["!sourcesDialog.open", !sourcesDialog.open],
|
||||
["sourcesDialog.labelledBy!=='sources-heading'", sourcesDialog.labelledBy!=='sources-heading'],
|
||||
["!sourcesDialog.focusInside", !sourcesDialog.focusInside],
|
||||
["!sourcesEscapeClosed", !sourcesEscapeClosed],
|
||||
["sourcesFocusRestored!=='sources-open'", sourcesFocusRestored!=='sources-open'],
|
||||
["!accountDialogFocusInside", !accountDialogFocusInside],
|
||||
["!accountEscapeClosed", !accountEscapeClosed],
|
||||
["accountFocusRestored!=='search-account-open'", accountFocusRestored!=='search-account-open'],
|
||||
["printState.chrome!=='none'", printState.chrome!=='none'],
|
||||
["printState.filters!=='none'", printState.filters!=='none'],
|
||||
["printState.results!=='none'", printState.results!=='none'],
|
||||
["printState.footer!=='none'", printState.footer!=='none'],
|
||||
["printState.document==='none'", printState.document==='none'],
|
||||
["printState.source==='none'", printState.source==='none'],
|
||||
["printState.sourceHidden", printState.sourceHidden],
|
||||
["tableOverflow.scrollWidth<=tableOverflow.clientWidth", tableOverflow.scrollWidth<=tableOverflow.clientWidth],
|
||||
["tableOverflow.pageWidth>tableOverflow.viewport", tableOverflow.pageWidth>tableOverflow.viewport],
|
||||
["reducedMotion.scrollBehavior!=='auto'", reducedMotion.scrollBehavior!=='auto'],
|
||||
["reducedMotion.transitionDuration!=='0s'", reducedMotion.transitionDuration!=='0s'],
|
||||
["searchError.message!=='Fixture search error'", searchError.message!=='Fixture search error'],
|
||||
["searchError.query!=='service-error'", searchError.query!=='service-error'],
|
||||
["searchError.role!=='status'", searchError.role!=='status'],
|
||||
["editionError!=='Fixture edition error'", editionError!=='Fixture edition error'],
|
||||
["editionRetry!=='\u041d\u0430\u043b\u043e\u0433 \u043d\u0430 \u043f\u0440\u0438\u0431\u044b\u043b\u044c'", editionRetry!=='Налог на прибыль'],
|
||||
["previousEditionSource!=='https://cbd.minjust.gov.kg/doc-1/edition/2023/ru'", previousEditionSource!=='https://cbd.minjust.gov.kg/doc-1/edition/2023/ru'],
|
||||
["!sourceDuringEditionLoad.hidden", !sourceDuringEditionLoad.hidden],
|
||||
["sourceDuringEditionLoad.href!==null", sourceDuringEditionLoad.href!==null],
|
||||
["sourceDuringEditionLoad.opened!==0", sourceDuringEditionLoad.opened!==0],
|
||||
["!sourceAfterEditionError.hidden", !sourceAfterEditionError.hidden],
|
||||
["sourceAfterEditionError.href!==null", sourceAfterEditionError.href!==null],
|
||||
["sourceAfterEditionError.opened!==0", sourceAfterEditionError.opened!==0],
|
||||
["sourceAfterEditionRetry.hidden", sourceAfterEditionRetry.hidden],
|
||||
["sourceAfterEditionRetry.opened!==1", sourceAfterEditionRetry.opened!==1],
|
||||
["sourceAfterEditionRetry.href!=='https://cbd.minjust.gov.kg/doc-1/edition/2024/ru'", sourceAfterEditionRetry.href!=='https://cbd.minjust.gov.kg/doc-1/edition/2024/ru'],
|
||||
["noEditions.message!=='\u0414\u043b\u044f \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0440\u0435\u0434\u0430\u043a\u0446\u0438\u0439 \u0441 \u0442\u0435\u043a\u0441\u0442\u043e\u043c.'", noEditions.message!=='Для документа нет доступных редакций с текстом.'],
|
||||
["noEditions.bodyChildren!==0", noEditions.bodyChildren!==0],
|
||||
["!noEditions.source.hidden", !noEditions.source.hidden],
|
||||
["noEditions.source.href!==null", noEditions.source.href!==null],
|
||||
["!noEditions.languageSelectorHidden", !noEditions.languageSelectorHidden],
|
||||
["languageFallback.language!=='ru'", languageFallback.language!=='ru'],
|
||||
["languageFallback.heading!=='Russian-only edition'", languageFallback.heading!=='Russian-only edition'],
|
||||
["!languageFallback.selectorHidden", !languageFallback.selectorHidden],
|
||||
["noEditionExpandedText!==''", noEditionExpandedText!==''],
|
||||
["!noEditionExpandedHeading.includes('\u041e \u0432\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u0432 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435')", !noEditionExpandedHeading.includes('О введении в действие')],
|
||||
["!noEditionExpandHidden", !noEditionExpandHidden],
|
||||
["editionErrorExpandedText!==''", editionErrorExpandedText!==''],
|
||||
["!editionErrorExpandedHeading.includes('\u041f\u043e\u0440\u044f\u0434\u043e\u043a \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f')", !editionErrorExpandedHeading.includes('Порядок заполнения')],
|
||||
["!editionErrorExpandHidden", !editionErrorExpandHidden],
|
||||
["!editionLoading", !editionLoading],
|
||||
["!accountTabWrap", !accountTabWrap],
|
||||
["!accountTabTarget.dialog", !accountTabTarget.dialog],
|
||||
["!accountReverseTabWrap.dialog", !accountReverseTabWrap.dialog],
|
||||
["longContent.titleWidths.some(item=>item.scroll>item.client)", longContent.titleWidths.some(item=>item.scroll>item.client)],
|
||||
["longContent.imageWidth>longContent.bodyWidth+1", longContent.imageWidth>longContent.bodyWidth+1],
|
||||
["longContent.pageWidth>longContent.viewport", longContent.pageWidth>longContent.viewport],
|
||||
["!labInitial.saveDisabled", !labLayout.initialSaveDisabled],
|
||||
["!labLayout.initialSaveText.includes('Сначала выполните поиск')", !labLayout.initialSaveText.includes('Сначала выполните поиск')],
|
||||
["labLayout.panelScroll<=labLayout.panelClient", labLayout.panelScroll<=labLayout.panelClient],
|
||||
["labGeometry.headingLeft!==24||labGeometry.subtitleLeft!==24||labGeometry.helpRight!==1416", labGeometry.headingLeft!==24||labGeometry.subtitleLeft!==24||labGeometry.helpRight!==1416],
|
||||
["labGeometry.workspaceActionGap>1||labGeometry.actionFooterGap>1", labGeometry.workspaceActionGap>1||labGeometry.actionFooterGap>1],
|
||||
["labGeometry.actionsHeight!==96||labGeometry.footerHeight!==44", labGeometry.actionsHeight!==96||labGeometry.footerHeight!==44],
|
||||
["!labLayout.footerVisible", !labLayout.footerVisible],
|
||||
["labLayout.publicFooterVisible", labLayout.publicFooterVisible],
|
||||
["labLayout.navHeight!==64", labLayout.navHeight!==64],
|
||||
["labLayout.titlebarHeight!==64", labLayout.titlebarHeight!==64],
|
||||
["labLayout.actionsHeight!==96", labLayout.actionsHeight!==96],
|
||||
["labLayout.footerHeight!==44", labLayout.footerHeight!==44],
|
||||
["labLayout.labTitleSize!==26", labLayout.labTitleSize!==26],
|
||||
["labLayout.sectionHeadingSize!==20", labLayout.sectionHeadingSize!==20],
|
||||
["Math.abs(labLayout.headerLeft-labLayout.workspaceLeft)>1", Math.abs(labLayout.headerLeft-labLayout.workspaceLeft)>1],
|
||||
["Math.abs(labLayout.headerRight-labLayout.workspaceRight)>1", Math.abs(labLayout.headerRight-labLayout.workspaceRight)>1],
|
||||
["!reviewFlow.reviewSaveEnabled", !reviewFlow.reviewSaveEnabled],
|
||||
["!reviewFlow.commentStartsCollapsed", !reviewFlow.commentStartsCollapsed],
|
||||
["!reviewFlow.commentExpanded", !reviewFlow.commentExpanded],
|
||||
["reviewFlow.draftStatus!=='Черновик сохранён на этом устройстве.'", reviewFlow.draftStatus!=='Черновик сохранён на этом устройстве.'],
|
||||
["reviewFlow.reviewScroll.internalScroll", !reviewFlow.reviewScroll.internalScroll],
|
||||
["reviewFlow.reviewScroll.height>reviewFlow.reviewScroll.viewport*0.69", reviewFlow.reviewScroll.height>reviewFlow.reviewScroll.viewport*0.69],
|
||||
["!reviewFlow.saveViewport.visible", !reviewFlow.saveViewport.visible],
|
||||
["!reviewFlow.saveViewport.footerVisible", !reviewFlow.saveViewport.footerVisible],
|
||||
["!reviewFlow.reviewScroll.saveAfter", !reviewFlow.reviewScroll.saveAfter],
|
||||
["!reviewFlow.reviewScroll.footerAfterSave", !reviewFlow.reviewScroll.footerAfterSave],
|
||||
["!reviewFlow.reviewSaved.includes('Оценка сохранена')", !reviewFlow.reviewSaved.includes('Оценка сохранена')]
|
||||
].filter(([, failed]) => failed).map(([name]) => name); console.log('labLayout',JSON.stringify(labLayout)); console.log('reviewFlow',JSON.stringify(reviewFlow)); console.log('failedCoreChecks', JSON.stringify(failedCoreChecks)); if (failedCoreChecks.length) process.exit(1);
|
||||
const usersPage=page; await usersPage.setViewportSize({width:320,height:760}); await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor(); const usersRoute=await usersPage.evaluate(()=>({visibleFooters:[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length,visible:!document.querySelector('#users-page').hidden,admin:document.body.classList.contains('is-admin'),accountOpen:document.querySelector('#account-dialog').open,headerVisible:getComputedStyle(document.querySelector('body > header.review__header')).display!=='none',footerAfterMain:Boolean(document.querySelector('#users-page').compareDocumentPosition(document.querySelector('body > footer.review__header')) & Node.DOCUMENT_POSITION_FOLLOWING),reviewActionsHidden:getComputedStyle(document.querySelector('body > .review__actions')).display==='none',footerAfterUsers:document.querySelector('body > footer.review__header').getBoundingClientRect().top>=document.querySelector('#user-list').getBoundingClientRect().bottom,overflow:document.documentElement.scrollWidth>innerWidth})); await usersPage.locator('#users-ui-ky').click(); await usersPage.waitForFunction(()=>[...document.querySelectorAll('#user-list .review__user-row')].some(row=>row.children[1]?.textContent.trim()==='Колдонуучу')); const usersKy=await usersPage.evaluate(()=>({lang:document.documentElement.lang,heading:document.querySelector('#users-heading').textContent})); const localizedRoleCells=await usersPage.locator('#user-list .review__user-row').evaluateAll(rows=>rows.map(row=>row.children[1].textContent)); const usersNavContract=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#review-review-nav').hidden})); console.log('usersRouteCheck',JSON.stringify({usersRoute,usersKy,usersNavContract,errors})); if(usersRoute.visibleFooters!==1||!usersRoute.visible||!usersRoute.admin||usersRoute.accountOpen||!usersRoute.headerVisible||!usersRoute.footerAfterMain||!usersRoute.reviewActionsHidden||!usersRoute.footerAfterUsers||usersRoute.overflow||errors.length||usersKy.lang!=='ky'||usersKy.heading!=='Колдонуучулар'||!localizedRoleCells.includes('Колдонуучу')||!usersNavContract.headerUsers||usersNavContract.dialogUsers||usersNavContract.dialogLab||!usersNavContract.headerLab) process.exit(1);
|
||||
const reflowWidth=640; await usersPage.setViewportSize({width:reflowWidth,height:450});
|
||||
await usersPage.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3'); await usersPage.locator('.search__result-title').first().waitFor();
|
||||
const search640=await usersPage.evaluate(()=>{const button=document.querySelector('#search-account-open'),r=button.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountVisible:r.width>0&&getComputedStyle(button).visibility!=='hidden',accountWithinViewport:r.left>=0&&r.right<=innerWidth};});
|
||||
await usersPage.locator('#search-account-open').click();
|
||||
const accountDialog640=await usersPage.evaluate(()=>{const dialog=document.querySelector('#account-dialog'),r=dialog.getBoundingClientRect(),actions=[...dialog.querySelectorAll('#review-nav,#search-users-open,#search-logout')].filter(el=>!el.hidden);return {open:dialog.open,withinViewport:r.left>=0&&r.right<=innerWidth,actionsVisible:actions.length>0&&actions.every(el=>{const a=el.getBoundingClientRect();return a.width>0&&a.left>=0&&a.right<=innerWidth;})};});
|
||||
await usersPage.keyboard.press('Escape');
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='admin'&&!document.body.classList.contains('review__locked'));
|
||||
const review640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth};}); const reviewNavContract=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#review-review-nav').hidden})); if(!reviewNavContract.headerUsers||reviewNavContract.dialogUsers||reviewNavContract.dialogLab||!reviewNavContract.headerLab) throw new Error(`Review account actions duplicate header navigation: ${JSON.stringify(reviewNavContract)}`);
|
||||
await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor();
|
||||
const users640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),create=document.querySelector('#create-user-open'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect(),c=create.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth,createVisible:c.width>0&&getComputedStyle(create).visibility!=='hidden',createWithinViewport:c.left>=0&&c.right<=innerWidth};});
|
||||
const reflow640={search640,accountDialog640,review640,users640}; console.log('reflow640',JSON.stringify(reflow640));
|
||||
if(search640.documentWidth>reflowWidth||!search640.accountVisible||!search640.accountWithinViewport||!accountDialog640.open||!accountDialog640.withinViewport||!accountDialog640.actionsVisible||review640.documentWidth>reflowWidth||!review640.accountWithinViewport||!review640.logoutVisible||!review640.logoutWithinViewport||users640.documentWidth>reflowWidth||!users640.accountWithinViewport||!users640.logoutVisible||!users640.logoutWithinViewport||!users640.createVisible||!users640.createWithinViewport) process.exit(1);
|
||||
await usersPage.locator('#create-user-open').click(); await usersPage.locator('#new-username').fill('fixture-created'); await usersPage.locator('#temporary-password').fill('Fixture-only-temporary-password-2026'); await usersPage.locator('#create-user-form button[type="submit"]').click(); await usersPage.waitForFunction(()=>!document.querySelector('#create-user-dialog').open); let createdRow=usersPage.locator('#user-list .review__user-row').filter({hasText:'fixture-created'}); await createdRow.waitFor();
|
||||
await createdRow.locator('.review__user-reset-disclosure summary').click(); await createdRow.locator('.review__user-reset input[type="password"]').fill('Fixture-only-reset-password-2026'); const resetResponse=usersPage.waitForResponse((response)=>new URL(response.url()).pathname==='/admin/users/fixture-created/reset-password'); await createdRow.locator('.review__user-reset button[type="submit"]').click(); await resetResponse;
|
||||
await usersPage.locator('#review-account-open').click(); await usersPage.locator('#security-tab').click(); const passwordToggle=usersPage.locator('#profile-password-form .review__password-toggle').first(); const securityCloseCount=await usersPage.locator('#security-panel [data-close="account-dialog"]').count(); await passwordToggle.click(); const passwordShown=await usersPage.locator('#profile-current-password').evaluate(input=>input.type==='text'&&input.parentElement.querySelector('.review__password-toggle').getAttribute('aria-pressed')==='true'); await passwordToggle.click(); const passwordHidden=await usersPage.locator('#profile-current-password').evaluate(input=>input.type==='password'&&input.parentElement.querySelector('.review__password-toggle').getAttribute('aria-pressed')==='false'); await usersPage.locator('#profile-current-password').fill('Fixture-only-current-password-2026'); await usersPage.locator('#profile-new-password').fill('Fixture-only-new-password-2026'); await usersPage.locator('#profile-confirm-password').fill('Fixture-only-new-password-2026'); const passwordResponse=usersPage.waitForResponse((response)=>new URL(response.url()).pathname==='/auth/change-password'); await usersPage.locator('#profile-password-form button[type="submit"]').click(); await passwordResponse; await usersPage.waitForFunction(()=>!document.querySelector('#account-dialog').open); if(securityCloseCount!==0||!passwordShown||!passwordHidden) process.exit(1); console.log('accountSecurity',JSON.stringify({securityCloseCount,passwordShown,passwordHidden}));
|
||||
const passwordAccountAfterChange=await usersPage.locator('#profile-username').textContent(); if(passwordAccountAfterChange!==passwordChangeAccount.username||!await usersPage.locator('#users-page').isVisible()) throw new Error('Fixture password change must return the account payload and restore the admin workspace.');
|
||||
createdRow=usersPage.locator('#user-list .review__user-row').filter({hasText:'fixture-created'}); await createdRow.waitFor(); await createdRow.locator('.review__user-actions > button').last().click(); await usersPage.locator('#delete-user-confirm').click(); await usersPage.waitForFunction(()=>![...document.querySelectorAll('#user-list .review__user-row')].some(row=>row.textContent.includes('fixture-created')));
|
||||
const safeFixtureOperations={passwordChanges:fixtureState.passwordChanges,userCreates:fixtureState.userCreates,passwordResets:fixtureState.passwordResets,userDeletes:fixtureState.userDeletes,reviewSaves:fixtureState.reviewSaves,createdUserRemoved:!fixtureState.users.some((user)=>user.username==='fixture-created')}; console.log('safeFixtureOperations',JSON.stringify(safeFixtureOperations));
|
||||
if(safeFixtureOperations.passwordChanges!==1||safeFixtureOperations.userCreates!==1||safeFixtureOperations.passwordResets!==1||safeFixtureOperations.userDeletes!==1||safeFixtureOperations.reviewSaves<2||!safeFixtureOperations.createdUserRemoved) process.exit(1);
|
||||
|
||||
const layoutRegressions={brand:{},mobile:{}};
|
||||
await usersPage.goto('https://fixture.invalid/'); await usersPage.locator('#search-results-heading').waitFor();
|
||||
for (const language of ['ru','ky']) {
|
||||
await usersPage.locator(`#ui-${language}`).click();
|
||||
await usersPage.waitForFunction(expected=>document.documentElement.lang===expected,language);
|
||||
for (const width of [1024,1280,1440]) {
|
||||
await usersPage.setViewportSize({width,height:900});
|
||||
await usersPage.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
layoutRegressions.brand[`${language}-${width}`]=await usersPage.evaluate(()=>{
|
||||
const brand=document.querySelector('.search__brand'), name=document.querySelector('.search__brand-name'), form=document.querySelector('.search__query');
|
||||
const range=document.createRange(); range.selectNodeContents(name);
|
||||
const rects=[...range.getClientRects()];
|
||||
return {brandRight:brand.getBoundingClientRect().right,textLeft:Math.min(...rects.map(rect=>rect.left)),textRight:Math.max(...rects.map(rect=>rect.right)),formLeft:form.getBoundingClientRect().left,accessibleName:brand.getAttribute('aria-label')};
|
||||
});
|
||||
}
|
||||
}
|
||||
await usersPage.locator('#ui-ru').click();
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='admin'&&!document.body.classList.contains('review__locked'));
|
||||
layoutRegressions.reviewNav=await usersPage.evaluate(()=>{
|
||||
const read=id=>{const el=document.getElementById(id),s=getComputedStyle(el),r=el.getBoundingClientRect();return {tag:el.tagName,href:el.getAttribute('href'),active:el.classList.contains('is-active'),height:r.height,display:s.display,alignItems:s.alignItems,color:s.color,background:s.backgroundColor,borderColor:s.borderTopColor};};
|
||||
return {search:read('review-search-nav'),lab:read('review-review-nav')};
|
||||
});
|
||||
await usersPage.keyboard.press('Tab'); await usersPage.keyboard.press('Tab');
|
||||
layoutRegressions.reviewNav.focused=await usersPage.evaluate(()=>({id:document.activeElement.id,outline:getComputedStyle(document.activeElement).outlineWidth}));
|
||||
await usersPage.goto('https://fixture.invalid/'); await usersPage.locator('#search-results-heading').waitFor();
|
||||
layoutRegressions.publicNav=await usersPage.evaluate(()=>{
|
||||
const read=id=>{const el=document.getElementById(id),s=getComputedStyle(el),r=el.getBoundingClientRect();return {tag:el.tagName,href:el.getAttribute('href'),active:el.classList.contains('is-active'),height:r.height,display:s.display,alignItems:s.alignItems,color:s.color,background:s.backgroundColor,borderColor:s.borderTopColor};};
|
||||
return {search:read('search-nav'),lab:read('search-review-nav')};
|
||||
});
|
||||
await usersPage.locator('#search-account-open').click();
|
||||
const publicAccountNav=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#search-review-nav').hidden}));
|
||||
await usersPage.keyboard.press('Escape');
|
||||
if(publicAccountNav.headerUsers||!publicAccountNav.dialogUsers||publicAccountNav.dialogLab||!publicAccountNav.headerLab) throw new Error(`Public account actions do not match visible header links: ${JSON.stringify(publicAccountNav)}`);
|
||||
await usersPage.setViewportSize({width:390,height:844});
|
||||
await usersPage.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C');
|
||||
await usersPage.locator('.search__result-title').first().waitFor(); await usersPage.locator('.search__result-title').first().click(); await usersPage.locator('#public-document-heading').waitFor();
|
||||
for (const width of [390,320]) {
|
||||
await usersPage.setViewportSize({width,height:844});
|
||||
await usersPage.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
layoutRegressions.mobile[width]=await usersPage.evaluate(()=>{
|
||||
const breadcrumb=document.querySelector('.search__breadcrumbs'),text=document.querySelector('#document-breadcrumb'),back=document.querySelector('#document-back'),range=document.createRange();
|
||||
range.selectNodeContents(text);
|
||||
const textRects=[...range.getClientRects()].map(({left,right,top,bottom})=>({left,right,top,bottom}));
|
||||
const backRect=back.getBoundingClientRect(),crumbRect=breadcrumb.getBoundingClientRect();
|
||||
const overlaps=textRects.some(rect=>rect.left<backRect.right&&rect.right>backRect.left&&rect.top<backRect.bottom&&rect.bottom>backRect.top);
|
||||
return {textRects,back:{left:backRect.left,right:backRect.right,top:backRect.top,bottom:backRect.bottom,height:backRect.height,tag:back.tagName,name:back.textContent.trim()},breadcrumb:{left:crumbRect.left,right:crumbRect.right,scrollWidth:breadcrumb.scrollWidth,clientWidth:breadcrumb.clientWidth},overlaps};
|
||||
});
|
||||
}
|
||||
console.log('layoutRegressions',JSON.stringify(layoutRegressions));
|
||||
const brandFailures=Object.entries(layoutRegressions.brand).filter(([,value])=>value.textRight>value.brandRight+0.5||value.formLeft-value.textRight<8||value.accessibleName!=='Акылдаш — поиск по правовой базе').map(([viewport])=>viewport);
|
||||
const navQuiet=value=>value.tag!=='A'||!value.href||value.active||value.height<44||value.display!=='flex'||value.alignItems!=='center'||value.color!=='rgb(157, 38, 51)'||value.background!=='rgb(255, 255, 255)'||value.borderColor!=='rgb(222, 211, 206)';
|
||||
const navPrimary=value=>value.tag!=='A'||!value.href||!value.active||value.height<44||value.display!=='flex'||value.alignItems!=='center'||value.color!=='rgb(255, 255, 255)'||value.background!=='rgb(157, 38, 51)'||value.borderColor!=='rgb(157, 38, 51)';
|
||||
const navFailures=navQuiet(layoutRegressions.reviewNav.search)||navPrimary(layoutRegressions.reviewNav.lab)||navQuiet(layoutRegressions.publicNav.lab)||navPrimary(layoutRegressions.publicNav.search)||layoutRegressions.reviewNav.focused.id!=='review-search-nav'||layoutRegressions.reviewNav.focused.outline!=='3px';
|
||||
const mobileFailures=Object.entries(layoutRegressions.mobile).filter(([,value])=>value.overlaps||value.back.tag!=='BUTTON'||value.back.name!=='К результатам'||value.back.height<44||value.textRects.some(rect=>rect.left<value.breadcrumb.left-1||rect.right>value.breadcrumb.right+1)).map(([viewport])=>viewport);
|
||||
if(brandFailures.length||navFailures||mobileFailures.length) throw new Error(JSON.stringify({brandFailures,navFailures,mobileFailures,layoutRegressions}));
|
||||
fixtureAccount=ordinaryAccount;
|
||||
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='fixture-user'&&!document.body.classList.contains('review__locked')); await usersPage.locator('#review').waitFor();
|
||||
const ordinaryNav=await usersPage.evaluate(()=>({role:document.querySelector('#profile-role').textContent,headerUsers:!document.querySelector('#review-users-open').hidden,accountUsers:!document.querySelector('#search-users-open').hidden,adminPage:document.body.classList.contains('is-admin')}));
|
||||
await usersPage.goto('https://fixture.invalid/users'); await usersPage.waitForURL('https://fixture.invalid/'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='fixture-user'&&!document.body.classList.contains('review__locked')); await usersPage.locator('#search-results-heading').waitFor();
|
||||
const ordinaryRoute=await usersPage.evaluate(()=>({path:location.pathname,usersHidden:document.querySelector('#users-page').hidden,adminPage:document.body.classList.contains('is-admin')}));
|
||||
console.log('ordinaryAccountCapability',JSON.stringify({ordinaryNav,ordinaryRoute}));
|
||||
if(ordinaryNav.role!=='Пользователь'||ordinaryNav.headerUsers||ordinaryNav.accountUsers||ordinaryNav.adminPage||ordinaryRoute.path!=='/'||!ordinaryRoute.usersHidden||ordinaryRoute.adminPage) throw new Error('Ordinary account must not receive user-management navigation or the users screen.');
|
||||
await browser.close();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,30 @@
|
||||
// Synthetic legal-interface examples. Shared by Playwright and the loopback preview.
|
||||
const docs = [
|
||||
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики',type:'Кодекс',status:'Действует',snippet:'… положения о налоге на прибыль и порядке его исчисления …',edition:'2024',editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']} ]},
|
||||
{code:'doc-2',name:'О введении в действие Налогового кодекса Кыргызской Республики',type:'Закон',status:'Действует',snippet:'… устанавливается порядок введения в действие Налогового кодекса Кыргызской Республики …'},
|
||||
{code:'doc-3',name:'Порядок заполнения налоговой отчётности',type:'Постановление',snippet:'… рекомендации по заполнению налоговой отчётности, включая показатели по налогу на прибыль …'}
|
||||
];
|
||||
const reviewDocs = [
|
||||
{code:'review-a',name:'Fixture document A',type:'fixture',status:'active',edition:'2026'},
|
||||
{code:'review-b',name:'Fixture document B',type:'fixture',status:'active',edition:'2025'}
|
||||
];
|
||||
const visualDocs = [
|
||||
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики · демонстрационная редакция',type:'Кодекс',status:'Действует',snippet:'Синтетический пример: общие положения о налоге на прибыль и порядке его расчёта.',edition:'2024'},
|
||||
{code:'doc-2',name:'Порядок расчёта налога на прибыль · учебный пример',type:'Методический материал',status:'Демонстрационный',snippet:'Иллюстративный материал для проверки отображения поисковой выдачи.',edition:'2023'}
|
||||
];
|
||||
const visualReviewDocs = [
|
||||
{code:'review-a',name:'Налог на прибыль: синтетический пример',type:'Демонстрационный документ',status:'Учебный пример',edition:'2026'},
|
||||
{code:'review-b',name:'Расчёт условной налоговой базы: синтетический пример',type:'Демонстрационный документ',status:'Учебный пример',edition:'2025'}
|
||||
];
|
||||
const visualArticle = '<h2>Налог на прибыль</h2><p>Синтетический текст для визуальной проверки интерфейса. Он не содержит ссылок на внешние адреса или загружаемые изображения.</p><p><b><span>Раздел I. Общие положения</span></b></p><p><b><span>Глава 1. Условный расчёт</span></b></p><p><b><span>Статья 1. Демонстрационный пример</span></b></p><p>Условная организация использует примерные данные, чтобы показать чтение текста и навигацию по структурным заголовкам.</p><h2>Порядок расчёта</h2><p>Все значения и формулировки в этом документе созданы только для интерфейсного preview.</p>';
|
||||
const visualReviewArticles = {
|
||||
'review-a':'<h2>Налог на прибыль: синтетический пример</h2><p>Демонстрационная редакция для проверки совместной работы результатов и панели документа.</p><p><b><span>Раздел I. Условные положения</span></b></p><p><b><span>Статья 1. Примерная расчётная база</span></b></p><p>Текст полностью вымышлен и используется только для локального fixture-preview.</p>',
|
||||
'review-b':'<h2>Расчёт условной налоговой базы</h2><p>Вымышленный текст для проверки панели чтения и элементов оценки.</p>'
|
||||
};
|
||||
const catalogs = {document_types:[{code:'code',labels:{ru:'Кодекс',ky:'Кодекс'},count:12},{code:'law',labels:{ru:'Закон',ky:'Мыйзам'},count:10},{code:'decree',labels:{ru:'Постановление',ky:'Токтом'},count:8}],statuses:[{code:'active',labels:{ru:'Действует',ky:'Күчүндө'},count:24},{code:'repealed',labels:{ru:'Утратил силу',ky:'Күчүн жоготту'},count:5}],authorities:[{code:'ministries_and_committees',labels:{ru:'Министерства и комитеты',ky:'Министрликтер'},count:5}],legal_forces:[]};
|
||||
const passwordChangeAccount = {username:'admin',role:'Администратор',can_manage_users:true,created_at:1780000000,must_change_password:false};
|
||||
const ordinaryAccount = {username:'fixture-user',role:'Пользователь',can_manage_users:false,created_at:1780000000,must_change_password:false};
|
||||
const article = '<h2>Налог на прибыль</h2><p><a href="../../tmp/old.html">Old local reference</a> <a href="http://cbd.minjust.gov.kg/old">Legacy HTTP reference</a> <a href="https://cbd.minjust.gov.kg/doc-1" ping="https://evil.invalid/ping">Official reference</a> <a href="https://127.0.0.1/private">Private address reference</a><img src="../../tmp/old.png" srcset="https://evil.invalid/pixel 1x, https://192.168.1.10/pixel 2x"><img alt="Private address image" src="https://192.168.1.10/pixel"></p><p><b><span>Раздел I. Общие положения</span></b></p><p><b><span>Глава 1. Основные положения</span></b></p><p><b><span>Статья 1. Основные понятия</span></b></p><p>Обычное упоминание статьи 9 не является заголовком.</p><p id="duplicate-heading"><b><span>Статья 1. Повторный номер</span></b></p><p id="duplicate-heading"><b><span>Раздел II. Налоговая отчётность</span></b></p><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
|
||||
const articleKy = '<h2>Киреше салыгы</h2><p><b><span>БӨЛҮМ 1. Жалпы жоболор</span></b></p><p><b><span>Глава 1. Негизги жоболор</span></b></p><p><b><span>Берене 1. Негизги түшүнүктөр</span></b></p><p>Текстте берене 9 жөнүндө кадимки эскерүү бар.</p>';
|
||||
|
||||
module.exports = {docs, reviewDocs, visualDocs, visualReviewDocs, visualArticle, visualReviewArticles, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy};
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "akyldash-frontend-visual-review",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"playwright": "1.64.1"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "akyldash-frontend-visual-review",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node check.cjs",
|
||||
"preview": "node preview-server.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "1.64.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {visualDocs, visualReviewDocs, visualReviewArticles, visualArticle, articleKy, catalogs, passwordChangeAccount} = require('./fixtures.cjs');
|
||||
|
||||
const repo = path.resolve(__dirname, '../..');
|
||||
const htmlPath = path.join(repo, 'backend/search/review.html');
|
||||
const staticRoot = path.join(repo, 'backend/search/static');
|
||||
const html = fs.readFileSync(htmlPath, 'utf8');
|
||||
const port = Number(process.argv[2] || 4173);
|
||||
let users = [{username:'admin',role:'Администратор',can_manage_users:true,must_change_password:false,last_login_at:null,first_login_at:1780000000,password_reset_at:null},{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:false,last_login_at:null,first_login_at:1780000000,password_reset_at:null}];
|
||||
|
||||
const warning = `<div style="position:relative;z-index:1000;padding:5px 12px;background:#38262a;color:#fff;font:600 12px/1.4 sans-serif;text-align:center;letter-spacing:.02em">ЛОКАЛЬНЫЙ PREVIEW · только синтетические данные · внешние ссылки отключены</div><script>document.addEventListener('click',event=>{const link=event.target.closest('a[href]');if(!link)return;const target=new URL(link.href,location.href);if(target.origin!==location.origin){event.preventDefault();event.stopImmediatePropagation();}},true);</script>`;
|
||||
const previewHtml = html.replace('<body>', `<body>${warning}`);
|
||||
const json = (response, status, value) => {
|
||||
response.writeHead(status, {'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Content-Type-Options':'nosniff'});
|
||||
response.end(JSON.stringify(value));
|
||||
};
|
||||
const readBody = (request) => new Promise((resolve) => {
|
||||
let body = '';
|
||||
request.on('data', (chunk) => { body += chunk; if (body.length > 32768) request.destroy(); });
|
||||
request.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); } });
|
||||
});
|
||||
const sendHtml = (response) => {
|
||||
response.writeHead(200, {
|
||||
'Content-Type':'text/html; charset=utf-8',
|
||||
'Cache-Control':'no-store',
|
||||
'X-Content-Type-Options':'nosniff',
|
||||
'Referrer-Policy':'no-referrer',
|
||||
'Content-Security-Policy':"default-src 'self' data:; connect-src 'self'; img-src 'self' data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; navigate-to 'self'"
|
||||
});
|
||||
response.end(previewHtml);
|
||||
};
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
const method = request.method || 'GET';
|
||||
if (method === 'GET' && ['/', '/review', '/users'].includes(url.pathname)) return sendHtml(response);
|
||||
if (method === 'GET' && url.pathname === '/auth/me') return json(response, 200, {account:passwordChangeAccount});
|
||||
if (method === 'POST' && url.pathname === '/auth/logout') return json(response, 200, {ok:true});
|
||||
if (method === 'POST' && url.pathname === '/auth/change-password') return json(response, 200, {account:passwordChangeAccount});
|
||||
if (method === 'GET' && url.pathname === '/search/filters') return json(response, 200, catalogs);
|
||||
if (method === 'GET' && url.pathname === '/search') {
|
||||
const query = url.searchParams.get('q') || '';
|
||||
const lab = url.searchParams.has('page_size') && !url.searchParams.has('sort');
|
||||
return json(response, 200, {results:query === 'not-found' ? [] : (lab ? visualReviewDocs : visualDocs),has_next:false,review_token:'fixture-only-token'});
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/documents/doc-2/editions') return json(response, 200, {available_languages:[],editions:[]});
|
||||
if (method === 'GET' && url.pathname === '/documents/doc-3/editions') return json(response, 503, {error:'Fixture editions error'});
|
||||
if (method === 'GET' && url.pathname === '/documents/doc-1/editions') return json(response, 200, {available_languages:['ru','ky'],editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года',ky:'2024-жылдын 12-январындагы редакция'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']}]});
|
||||
const visualReviewEdition = url.pathname.match(/^\/documents\/(review-a|review-b)\/editions\/(2026|2025)$/);
|
||||
if (method === 'GET' && visualReviewEdition) return json(response, 200, {content:{ru:{html:visualReviewArticles[visualReviewEdition[1]]}}});
|
||||
const edition = url.pathname.match(/^\/documents\/doc-1\/editions\/(2024|2023)$/);
|
||||
if (method === 'GET' && edition) {
|
||||
const language = url.searchParams.get('language') || 'ru';
|
||||
const content = edition[1] === '2023' ? '<h2>Russian-only edition</h2><p>Regression fixture.</p>' : language === 'ky' ? articleKy : visualArticle;
|
||||
return json(response, 200, {content:{[language]:{html:content}}});
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/admin/users') return json(response, 200, {users});
|
||||
if (method === 'POST' && url.pathname === '/admin/users') {
|
||||
const body = await readBody(request); const username = String(body.username || 'fixture-created');
|
||||
users.push({username,role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null});
|
||||
return json(response, 201, {username});
|
||||
}
|
||||
if (method === 'POST' && /^\/admin\/users\/[^/]+\/reset-password$/.test(url.pathname)) return json(response, 200, {ok:true});
|
||||
if (method === 'DELETE' && /^\/admin\/users\/[^/]+$/.test(url.pathname)) return json(response, 200, {ok:true});
|
||||
if (method === 'POST' && url.pathname === '/search-reviews') return json(response, 200, {id:42});
|
||||
if (method === 'GET' && url.pathname.startsWith('/static/')) {
|
||||
const name = path.basename(url.pathname);
|
||||
const file = path.join(staticRoot, url.pathname.endsWith('.woff2') ? 'fonts' : '', name);
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return json(response, 404, {error:'Not found'});
|
||||
const type = name.endsWith('.woff2') ? 'font/woff2' : name.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
|
||||
response.writeHead(200, {'Content-Type':type,'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'});
|
||||
return fs.createReadStream(file).pipe(response);
|
||||
}
|
||||
return json(response, 404, {error:'Fixture route not found'});
|
||||
});
|
||||
|
||||
server.on('error', (error) => { process.stderr.write(`${error.code || error.name}\n`); process.exitCode = 1; });
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
process.stdout.write(`fixture preview listening at http://127.0.0.1:${port}/ (pid ${process.pid})\n`);
|
||||
});
|
||||