diff --git a/components/traffic/geoip-settings-section.tsx b/components/traffic/geoip-settings-section.tsx index d0c27f4f..76837ae9 100644 --- a/components/traffic/geoip-settings-section.tsx +++ b/components/traffic/geoip-settings-section.tsx @@ -84,11 +84,12 @@ function GeoipSettingsContent({ backendUrl }: { backendUrl: string }) { if (reader.current.active) await load() } const markDirty = () => { dirtyRef.current = true; setDirty(true) } - return Country: {status ? status.countryLoaded ? "загружена" : "не загружена" : "—"}ASN: {status ? status.asnLoaded ? "загружена" : "не загружена" : "—"}}> + return +
Country: {status ? status.countryLoaded ? "загружена" : "не загружена" : "—"}ASN: {status ? status.asnLoaded ? "загружена" : "не загружена" : "—"}
{!can("mm:traffic:read") ?

Нет доступа к данным GeoIP

:

{readBusy ? "Загрузка GeoIP…" : status ? "Статус GeoIP получен: " + date(receivedAt) : "Статус GeoIP не получен"}

} {readError ? : null} {opError ? : null} - {needsReview ?

Проверьте журнал планировщика и состояние баз. Повторное обновление запустит новую операцию.

: null} + {needsReview ?

Проверьте журнал планировщика и состояние баз. Повторное обновление запустит новую операцию.

: null} {outcome ?

{outcome}

: null}
{ markDirty(); setAutoOn(value) }} />Автообновление{!status ? ": —" : ""}
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 1340e8ab..3526b232 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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; внешние ограничения — журнал. | Экран | ПК | Телефон | | --- | --- | --- | diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 4eab9521..76acf9ff 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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 отключены. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 5d4a6a46..1764232e 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -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 ещё не принят. diff --git a/docs/ux-ui/screens/geoip-1440.png b/docs/ux-ui/screens/geoip-1440.png new file mode 100644 index 00000000..44f5cecc Binary files /dev/null and b/docs/ux-ui/screens/geoip-1440.png differ diff --git a/docs/ux-ui/screens/geoip-320.png b/docs/ux-ui/screens/geoip-320.png new file mode 100644 index 00000000..fad9b984 Binary files /dev/null and b/docs/ux-ui/screens/geoip-320.png differ diff --git a/docs/ux-ui/screens/geoip-390.png b/docs/ux-ui/screens/geoip-390.png new file mode 100644 index 00000000..0fc7d34f Binary files /dev/null and b/docs/ux-ui/screens/geoip-390.png differ diff --git a/playwright.config.ts b/playwright.config.ts index 956418e4..485bc0d3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/playwright.geoip-context.config.ts b/playwright.geoip-context.config.ts new file mode 100644 index 00000000..525c7783 --- /dev/null +++ b/playwright.geoip-context.config.ts @@ -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" } } }) diff --git a/tests/e2e/geoip-context.spec.ts b/tests/e2e/geoip-context.spec.ts new file mode 100644 index 00000000..95c8437b --- /dev/null +++ b/tests/e2e/geoip-context.spec.ts @@ -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(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() } +}) diff --git a/tests/e2e/geoip-responsive.spec.ts b/tests/e2e/geoip-responsive.spec.ts new file mode 100644 index 00000000..d89cc705 --- /dev/null +++ b/tests/e2e/geoip-responsive.spec.ts @@ -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 = { + "/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 }) +})