fix(geoip): проверить смену источника и читаемость на телефоне

This commit is contained in:
Denozordec committed 2026-10-03 12:19:54 +07:00
1 parent 57e7dcf990
commit 52cb93bf24
11 files changed
+95 -5

No files matched your search

@@ -84,11 +84,12 @@ function GeoipSettingsContent({ backendUrl }: { backendUrl: string }) {
if (reader.current.active) await load()
}
const markDirty = () => { dirtyRef.current = true; setDirty(true) }
return <OpsPanel title="GeoIP-базы (GeoLite2)" description="Страны и ASN потоков определяются по локальным базам MaxMind GeoLite2 с зеркала P3TERX." contentClassName="px-5 py-4 flex flex-col gap-4" headerRight={<div className="flex items-center gap-2"><Badge variant={status?.countryLoaded ? "success" : "secondary"}>Country: {status ? status.countryLoaded ? "загружена" : "не загружена" : "—"}</Badge><Badge variant={status?.asnLoaded ? "success" : "secondary"}>ASN: {status ? status.asnLoaded ? "загружена" : "не загружена" : "—"}</Badge></div>}>
return <OpsPanel title="GeoIP-базы (GeoLite2)" description="Страны и ASN потоков определяются по локальным базам MaxMind GeoLite2 с зеркала P3TERX." contentClassName="px-5 py-4 flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2"><Badge variant={status?.countryLoaded ? "success" : "secondary"}>Country: {status ? status.countryLoaded ? "загружена" : "не загружена" : "—"}</Badge><Badge variant={status?.asnLoaded ? "success" : "secondary"}>ASN: {status ? status.asnLoaded ? "загружена" : "не загружена" : "—"}</Badge></div>
{!can("mm:traffic:read") ? <p role="status">Нет доступа к данным GeoIP</p> : <p role="status" className="text-xs text-muted-foreground">{readBusy ? "Загрузка GeoIP…" : status ? "Статус GeoIP получен: " + date(receivedAt) : "Статус GeoIP не получен"}</p>}
{readError ? <Alert variant="warning"><AlertCircleIcon aria-hidden="true" /><AlertTitle>{status ? "GeoIP не обновлён" : "GeoIP не получен"}</AlertTitle><AlertDescription>{readError}{status ? ". Показаны последние подтверждённые данные: " + date(receivedAt) : ""}</AlertDescription></Alert> : null}
{opError ? <Alert variant="destructive"><AlertCircleIcon aria-hidden="true" /><AlertTitle>Операция GeoIP требует внимания</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert> : null}
{needsReview ? <div className="flex flex-col gap-2"><p className="text-xs text-muted-foreground">Проверьте журнал планировщика и состояние баз. Повторное обновление запустит новую операцию.</p><Button type="button" size="sm" variant="outline" disabled={!writable || busy || readBusy || status?.running} onClick={() => { setNeedsReview(false); setOpError(null) }}>Разрешить новое обновление GeoIP</Button></div> : null}
{needsReview ? <div className="flex flex-col gap-2"><p className="text-xs text-muted-foreground">Проверьте журнал планировщика и состояние баз. Повторное обновление запустит новую операцию.</p><Button type="button" size="sm" variant="outline" aria-label="Разрешить новое обновление GeoIP" disabled={!writable || busy || readBusy || status?.running} onClick={() => { setNeedsReview(false); setOpError(null) }}>Разрешить повтор GeoIP</Button></div> : null}
{outcome ? <p role="status" className="text-sm">{outcome}</p> : null}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-3"><FormToggle ariaLabel="Автообновление GeoIP" checked={autoOn} disabled={!writable || busy} onChange={value => { markDirty(); setAutoOn(value) }} /><span className="text-sm">Автообновление{!status ? ": —" : ""}</span></div>
+1 -1
View File
@@ -226,7 +226,7 @@ REST запрос → ожидание → подтверждённый output /
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.4.3 — GeoIP source/визуальная приёмка; затем strict NetFlow API/форма. GeoIP known/dirty/partial/unknown guard приняты в 11Б.3.4.2. GeoIP RBAC исправлен в 11Б.3.4.1 (traffic read/write вместо catch-all dashboard read). Scheduler принят локально в 11Б.3.3.7 (46 основных + 6 backend source E2E, ПК/390/320); внешние интеграции не доказаны. Параллельных агентов не использовать. Ручные run-now/refresh, общий lock/права и сообщения skipped/partial/event/status приняты в 11Б.3.3.6.3. Точечные операции настроек, ввод и подтверждённый сброс dirty приняты в 11Б.3.3.6.2. Независимые strict settings readers и защита черновиков подключены в 11Б.3.3.5; реальные интеграции остаются внешней приёмкой. Независимые scheduler status/runs и достоверные KPI приняты в 11Б.3.3.3. Strict history/unknown/свежесть приняты в 11Б.3.3.2. Контекст/abort/revision и исходный lint исправлены в 11Б.3.3.1; далее независимые readers/dirty states и операции. GeoIP API принят в 11Б.3.2. Scheduler API основа проверена в 11Б.3.1, пока не подключена к странице. Совместимость JSONB/legacy snapshot и foreign guard проверены в 11Б.2; исходные lint/context ограничения страницы описаны в журнале. Общий runner исправлен в 11Б.1; карта оставшегося этапа — [11Б](ux-ui-stage-11b.md). Этап 11А принят локально в 11А.33: backups 71 основных + 28 source E2E, 27 frontend unit, backend профиль и production build. Backup scheduler cleanup проверен в 11А.32; общий dispatcher/journal status исправлен в 11Б.1, реальные интеграции остаются внешней приёмкой. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А приняты локально в 11А.33; внешние ограничения — журнал.
Каталог Evolution защищён от неполного HTTP-ответа в 11Б.3.3.4.1; Подтверждение testConnection исправлено в 11Б.3.3.4.2; состояния интеграции остаются в объёме 11Б. Актуальная точка продолжения: 11Б.3.4.4 — строгие NetFlow API и backend эффекты, затем форма. GeoIP принят локально в 11Б.3.4.3 (13 основных + 6 source E2E, ПК/390/320); реальные интеграции не проверены. GeoIP known/dirty/partial/unknown guard приняты в 11Б.3.4.2. GeoIP RBAC исправлен в 11Б.3.4.1 (traffic read/write вместо catch-all dashboard read). Scheduler принят локально в 11Б.3.3.7 (46 основных + 6 backend source E2E, ПК/390/320); внешние интеграции не доказаны. Параллельных агентов не использовать. Ручные run-now/refresh, общий lock/права и сообщения skipped/partial/event/status приняты в 11Б.3.3.6.3. Точечные операции настроек, ввод и подтверждённый сброс dirty приняты в 11Б.3.3.6.2. Независимые strict settings readers и защита черновиков подключены в 11Б.3.3.5; реальные интеграции остаются внешней приёмкой. Независимые scheduler status/runs и достоверные KPI приняты в 11Б.3.3.3. Strict history/unknown/свежесть приняты в 11Б.3.3.2. Контекст/abort/revision и исходный lint исправлены в 11Б.3.3.1; далее независимые readers/dirty states и операции. GeoIP API принят в 11Б.3.2. Scheduler API основа проверена в 11Б.3.1, пока не подключена к странице. Совместимость JSONB/legacy snapshot и foreign guard проверены в 11Б.2; исходные lint/context ограничения страницы описаны в журнале. Общий runner исправлен в 11Б.1; карта оставшегося этапа — [11Б](ux-ui-stage-11b.md). Этап 11А принят локально в 11А.33: backups 71 основных + 28 source E2E, 27 frontend unit, backend профиль и production build. Backup scheduler cleanup проверен в 11А.32; общий dispatcher/journal status исправлен в 11Б.1, реальные интеграции остаются внешней приёмкой. S3/partial job/initial errors приняты в 11А.31.2.2. Диалоги ошибок приняты в 11А.31.2.1. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А приняты локально в 11А.33; внешние ограничения — журнал.
| Экран | ПК | Телефон |
| --- | --- | --- |
+9 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.2; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.3; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11Б.3.4.3 — локальная приёмка GeoIP (2026-10-03)
- 6 backend source E2E проверили поздние GET/PUT/POST, success/error старого адреса не меняют новый раздел. POST assertions уточнены для сообщения с фактическими счётчиками; оба POST source теста повторно прошли.
- 13 основных GeoIP E2E (10 сценариев + ПК/390/320) прошли, сохранены 3 снимка docs/ux-ui/screens/geoip*.png. На телефоне бейджи перенесены из headerRight в переносимую строку: заголовок больше не сжимается и не скрывается. Кнопка разрешения нового POST имеет короткую подпись и полное доступное имя.
- Первоначальный overflow assertion выявлял также штатную отрицательную границу Frame (+1px); допуск ограничен 2px, body не переполняется. Снимок 320 просмотрен и обнаруженное фактическое сжатие заголовка исправлено, тесты повторены. Это не утверждение о всей NetFlow панели.
- ESLint и production frontend/contracts build прошли. Backend JWT gate/permissions/build приняты в 11Б.3.4.1; реальные Portal/DB/mmdb не проверены. Unknown guard текущего раздела не долговечен между reload/navigation.
- Далее 11Б.3.4.4: строгие NetFlow settings/keys/export/purge API и backend эффекты. Выявлены создание ключей в GET host-files, выдача конфигов с private key по traffic read и маскировка успешного purge ошибкой события; также требуется проверка атомарности purge. UI NetFlow ещё не принят.
## Блок 11Б.3.4.2 — достоверная форма GeoIP (2026-10-03)
- GeoIP вынесен из NetFlow в отдельный раздел с backend key/remount, abort/revision и active guards. Независимые read/operation ошибки, browser receivedAt и последние данные сохраняются. Unknown не выдаётся за отсутствующие базы; формы/действия до подтверждения или без traffic write отключены.
+8
View File
@@ -169,3 +169,11 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
- Частичный update показывает скачанное/без изменений/bytes вместе с ошибками, не сообщает полного успеха. Skipped отдельно. Неизвестный POST не повторяется; новое обновление требует явного действия после проверки журнала/баз. Guard живёт в текущем разделе, не является durable backend идемпотентностью.
- Прошли 10 новых GeoIP E2E, 2 ранее существовавших malformed tests, ESLint и production frontend/contracts build. После добавления explicit unknown guard профильный POST тест повторно прошёл. Проверены fractional interval, invalid/no HTTP, false/unknown, partial, dirty, lock и reader claims; signed JWT gate принят в 11Б.3.4.1.
- NetFlow сохраняет собственные известные ограничения; его synchronous-effect lint исправлен очередью microtask, не считать это защитой от поздних ответов. Далее 11Б.3.4.3 — GeoIP source/визуальная приёмка, затем strict NetFlow API/форма. Реальная загрузка mmdb/DB/Portal не проверена.
## Блок 11Б.3.4.3 — локальная приёмка GeoIP (2026-10-03)
- 6 backend source E2E проверили поздние GET/PUT/POST, success/error старого адреса не меняют новый раздел. POST assertions уточнены для сообщения с фактическими счётчиками; оба POST source теста повторно прошли.
- 13 основных GeoIP E2E (10 сценариев + ПК/390/320) прошли, сохранены 3 снимка docs/ux-ui/screens/geoip*.png. На телефоне бейджи перенесены из headerRight в переносимую строку: заголовок больше не сжимается и не скрывается. Кнопка разрешения нового POST имеет короткую подпись и полное доступное имя.
- Первоначальный overflow assertion выявлял также штатную отрицательную границу Frame (+1px); допуск ограничен 2px, body не переполняется. Снимок 320 просмотрен и обнаруженное фактическое сжатие заголовка исправлено, тесты повторены. Это не утверждение о всей NetFlow панели.
- ESLint и production frontend/contracts build прошли. Backend JWT gate/permissions/build приняты в 11Б.3.4.1; реальные Portal/DB/mmdb не проверены. Unknown guard текущего раздела не долговечен между reload/navigation.
- Далее 11Б.3.4.4: строгие NetFlow settings/keys/export/purge API и backend эффекты. Выявлены создание ключей в GET host-files, выдача конфигов с private key по traffic read и маскировка успешного purge ошибкой события; также требуется проверка атомарности purge. UI NetFlow ещё не принят.
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
testIgnore: ["**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
fullyParallel: false,
workers: 1,
retries: 0,
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "@playwright/test"
import base from "./playwright.config"
const server = base.webServer
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
export default defineConfig({ ...base, use: { ...base.use, baseURL: "http://127.0.0.1:3101" }, testIgnore: [], testMatch: "geoip-context.spec.ts", webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local" } } })
+34
View File
@@ -0,0 +1,34 @@
import { test, expect, type Page } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
const common = { enabled: true, intervalSec: 45, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null }
const job = { jobKey: "backups", enabled: true, intervalSec: 60, running: false, lastFinishedAt: null, lastStatus: null, lastDurationMs: 0, lastError: null }
async function setup(page: Page) {
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/auth/config") return r.fulfill({ headers, json: { required: false } })
if (path === "/api/geoip") return r.fulfill({ headers, json: { ...geoip, settings: { ...geoip.settings, updateIntervalSec: new URL(r.request().url()).port === "8101" ? 648000 : 604800 } } })
if (path === "/api/scheduler/status") return r.fulfill({ headers, json: { jobs: [job] } })
if (path === "/api/scheduler/runs") return r.fulfill({ headers, json: { runs: [] } })
if (path === "/api/traffic/settings") return r.fulfill({ headers, json: { ...common, intervalSec: new URL(r.request().url()).port === "8101" ? 77 : 45 } })
return r.fulfill({ headers, status: 503, json: { error: "outside context fixture" } })
})
}
for (const action of ["read", "put", "run"] as const) for (const fails of [false, true]) test("GeoIP: поздний ответ после смены backend/" + action + "/" + fails, async ({ page }) => {
await setup(page); let started = false, completed = false, release!: () => void; const gate = new Promise<void>(resolve => release = resolve)
const path = action === "run" ? "/api/geoip/update" : "/api/geoip"
await page.route("**" + path, async r => {
if (new URL(r.request().url()).port !== "8000" || (action === "read" ? r.request().method() !== "GET" : action === "put" ? r.request().method() !== "PUT" : false)) return r.fallback()
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРАЯ ОШИБКА" } } : { headers, json: action === "run" ? { ok: true, snapshot: { v: 1, job: "geoip_update", sampledAt: "2026-10-03T00:00:00Z", checked: 2, downloaded: 2, skippedUnchanged: 0, bytes: 1024, errors: [] } } : action === "put" ? { ok: true, status: { ...geoip, settings: { ...geoip.settings, updateIntervalSec: 684000 } } } : { ...geoip, settings: { ...geoip.settings, updateIntervalSec: 684000 } } }) } catch {} finally { completed = true }
})
try {
await page.goto("/data-collection")
if (action === "put") { const field = page.getByRole("textbox", { name: "Интервал GeoIP в часах", exact: true }); await expect(field).toHaveValue("168"); await field.fill("190"); await page.getByRole("button", { name: "Сохранить GeoIP" }).click() }
if (action === "run") { const run = page.getByRole("button", { name: "Обновить сейчас", exact: true }); await expect(run).toBeEnabled(); await run.click() }
await expect.poll(() => started).toBe(true); await page.goto("/settings"); const url = page.getByPlaceholder("http://localhost:8000"); await url.fill("http://localhost:8101"); await url.press("Enter"); await page.goto("/data-collection")
await expect(page.getByRole("textbox", { name: "Интервал GeoIP в часах", exact: true })).toHaveValue("180"); release(); await expect.poll(() => completed).toBe(true)
await expect(page.getByRole("textbox", { name: "Интервал GeoIP в часах", exact: true })).toHaveValue("180"); await expect(page.getByText(/СТАРАЯ ОШИБКА/)).toHaveCount(0); await expect(page.getByText("Сохранение настроек GeoIP подтверждено", { exact: true })).toHaveCount(0); await expect(page.getByRole("status").filter({ hasText: "Обновление GeoIP подтверждено" })).toHaveCount(0)
} finally { release() }
})
+34
View File
@@ -0,0 +1,34 @@
import { test, expect, type Page } from "@playwright/test"
import { geoip } from "./fixtures/geoip"
const common = { enabled: true, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null }
const values: Record<string, object> = {
"/api/traffic/settings": { ...common, intervalSec: 45 },
"/api/servers-api-ping/settings": { ...common, enabled: false, intervalSec: 120 },
"/api/uptime/settings": { ...common, resourcesEnabled: true, pingEnabled: false, speedEnabled: false, probeIntervalSec: 15, speedIntervalSec: 60 },
"/api/internet-path/settings": common,
"/api/certificates/renew-settings": { enabled: false, intervalSec: 21600, renewBeforeDays: 30 },
}
async function setup(page: Page) {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/auth/config") return r.fulfill({ json: { required: false } })
if (path === "/api/geoip") return r.fulfill({ json: geoip })
if (path === "/api/scheduler/status") return r.fulfill({ json: { jobs: [] } })
if (path === "/api/scheduler/runs") return r.fulfill({ json: { runs: [] } })
if (path === "/api/traffic/flow/settings") return r.fulfill({ json: { enabled: false, collectorIp: "10.255.254.1", flowListenPort: 4739, wgListenPort: 51821, prefix: "10.255.254.0/24", publicEndpoint: "", hostPublicKey: "", hasHostPrivateKey: false, hubServerId: null, retentionHours: 24, topN: 200, mapServiceMinSharePct: 5, lastDatagramAt: null, lastExporterIp: null, lastError: null, packetsReceived: 0, listenerBound: false, listenerAddress: null, peers: [] } })
if (values[path]) return r.fulfill({ json: values[path] })
return r.fulfill({ status: 503, json: { error: "outside settings fixture" } })
})
}
for (const width of [1440, 390, 320]) test("GeoIP: адаптивность/" + width, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 }); await setup(page); await page.goto("/data-collection")
const section = page.getByText("GeoIP-базы (GeoLite2)", { exact: true }).locator("xpath=ancestor::*[@data-slot='frame'][1]")
await expect(page.getByRole("textbox", { name: "Интервал GeoIP в часах", exact: true })).toHaveValue("168")
await section.scrollIntoViewIfNeeded()
expect(await section.evaluate(element => element.scrollWidth <= element.clientWidth + 2)).toBe(true)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/geoip-" + width + ".png", fullPage: true })
})