diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index 45ad3030..ab324af5 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -41,7 +41,7 @@ import { useBackupRestoreGuard } from "@/lib/use-backup-restore-guard" import { restoreWasNotApplied } from "@/lib/backup-restore-checkpoint" import { Checkbox } from "@/components/ui/checkbox" import { useBackupJob } from "@/lib/use-backup-job" -import { backupJobOutcome } from "@/lib/backup-job-poll" +import { backupJobOutcome, backupJobResultError } from "@/lib/backup-job-poll" import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings" import { BackupsHistory } from "@/components/backups/backups-history" import { @@ -175,6 +175,7 @@ function BackupsContent() { const checkpoint = job.checkpoint const jobTerminal = !!checkpoint?.job && ["done", "failed"].includes(checkpoint.job.status) const backupJobId = checkpoint && !jobTerminal ? checkpoint.request.requestId : null + const persistedJobError = checkpoint?.job && jobTerminal ? backupJobResultError(checkpoint.job) : null const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError function startSettingsMutation() { @@ -419,7 +420,7 @@ function BackupsContent() { } {opError && Операция не подтверждена{opError}} - {(job.error || job.storageError) && {jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}{job.error || job.storageError}} + {(job.error || job.storageError || persistedJobError) && {jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}{job.error || job.storageError || persistedJobError}}

Резервные копии

История файлов, расписание и подтверждения операций

{ if (job.reset()) { setManualServers(new Set()); setManualNotes(""); setManualStep(1) } }} onSubmit={handleManualBackup} diff --git a/components/backups/backup-create-sheet.tsx b/components/backups/backup-create-sheet.tsx index fd653dd0..e81abe85 100644 --- a/components/backups/backup-create-sheet.tsx +++ b/components/backups/backup-create-sheet.tsx @@ -26,6 +26,7 @@ import { StepperTitle, StepperTrigger, } from "@/components/reui/stepper" +import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert" import { cn } from "@/lib/utils" export function BackupCreateSheet({ @@ -49,6 +50,7 @@ export function BackupCreateSheet({ jobId, jobStatus, result, + error, onReset, }: { open: boolean @@ -70,6 +72,7 @@ export function BackupCreateSheet({ jobId?: string jobStatus?: string result?: string + error?: string | null onReset: () => void onSubmit: () => void }) { @@ -78,10 +81,12 @@ export function BackupCreateSheet({ {locked ? "Задача резервного копирования" : "Новый бэкап"} - Снять конфигурацию вручную с выбранных серверов + {locked ? "Подтверждённые файлы и ошибки выполнения" : "Снять конфигурацию вручную с выбранных серверов"} - {locked &&

ID: {jobId}

{jobStatus}

{result &&

{result}

}

Закрытие формы не отменяет задачу. Повторная проверка не создаёт новые копии.

} +
+ {locked &&

ID: {jobId}

{jobStatus}

{result &&

{result}

}

Закрытие формы не отменяет задачу. Повторная проверка не создаёт новые копии.

} + {error &&
{terminal ? "Результат с ограничениями" : "Запрос не подтверждён"}{error}
}
@@ -166,16 +171,17 @@ export function BackupCreateSheet({

- Будет создан бэкап для {selected.size} серверов. + {locked ? "Запрошены резервные копии для" : "Будет создан бэкап для"} {selected.size} серверов.

-

+ {!locked &&

Куда сохранится: {destinationLabel} -

+

} {notes ?

Заметка: {notes}

: null}
+
}>Закрыть diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 5c11ab44..5fcebe83 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) | -Актуальная точка продолжения: 11А.31.2.2 — S3/partial job/initial errors и визуальная приёмка резервных копий. Диалоги ошибок приняты в 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А.32 — backup scheduler cleanup, затем профильная приёмка backups. 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А ещё не приняты. | Экран | ПК | Телефон | | --- | --- | --- | @@ -242,3 +242,10 @@ REST запрос → ожидание → подтверждённый output / | Ошибка удаления | [PNG](ux-ui/screens/backups-delete-error-desktop.png) | [390](ux-ui/screens/backups-delete-error-mobile.png), [320](ux-ui/screens/backups-delete-error-narrow.png) | | Неизвестный импорт | [PNG](ux-ui/screens/backups-restore-unknown-desktop.png) | [390](ux-ui/screens/backups-restore-unknown-mobile.png), [320](ux-ui/screens/backups-restore-unknown-narrow.png) | + +| Экран | ПК | Телефон | +| --- | --- | --- | +| S3 ошибка | [PNG](ux-ui/screens/backups-s3-error-desktop.png) | [390](ux-ui/screens/backups-s3-error-mobile.png), [320](ux-ui/screens/backups-s3-error-narrow.png) | +| Partial job | [PNG](ux-ui/screens/backups-partial-job-desktop.png) | [390](ux-ui/screens/backups-partial-job-mobile.png), [320](ux-ui/screens/backups-partial-job-narrow.png) | +| Initial error | [PNG](ux-ui/screens/backups-initial-error-desktop.png) | [390](ux-ui/screens/backups-initial-error-mobile.png), [320](ux-ui/screens/backups-initial-error-narrow.png) | + diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index a0166eeb..7a221adf 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -27,7 +27,7 @@ | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | -| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.31.2.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: Frame/диалоги, далее 11А.31.2.2 | +| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.31.2.2; пользователи/контейнеры/сертификаты приняты локально; резервные копии: визуальные состояния, далее 11А.32 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,19 @@ +## Блок 11А.31.2.2 — визуальные состояния S3, partial job и initial error (2026-10-01) + +- Проверены ПК/390/320: S3 отказ доступа с актуальным backend receipt, partial upload при созданной локальной копии, первоначальный отказ истории. Unknown не превращается в empty/нулевые KPI, старая проверка S3 не остаётся успешной после подтверждённой ошибки. Добавлены 9 PNG. +- Просмотр partial job выявил скрытую под Sheet ошибку. Ошибка и «Завершена с ограничениями» теперь показаны внутри формы; общий parent-owned scroll оставляет footer доступным. Результат после reload восстанавливается из terminal checkpoint, не зависит от прежнего состояния hook error. Locked запрос говорит о выбранной цели, не обещает будущее сохранение в текущую конфигурацию хранилища. +- 9 states + 9 job E2E (повтор с reload partial), 3 layout регрессии предыдущим прогоном, 6 polling/result unit, TypeScript/профильный ESLint и production frontend прошли. ReUI audit checklist получен; установленная композиция/semantic tokens/typed API сохранены, клавиатура/scroll/ошибки проверены в профиле. Реальная DB/RouterOS/S3 остаётся внешней приёмкой. +- **Следующий блок 11А.32:** backup scheduler cleanup — lock на всех выходах/setup failure, независимость журнала от подтверждённых файлов, отдельные prune/metadata сбои. Затем 11А.33 профильная локальная приёмка backups; весь 11А ещё не принят. + +| Экран | ПК | Телефон | +| --- | --- | --- | +| S3 ошибка | [PNG](ux-ui/screens/backups-s3-error-desktop.png) | [390](ux-ui/screens/backups-s3-error-mobile.png), [320](ux-ui/screens/backups-s3-error-narrow.png) | +| Partial job | [PNG](ux-ui/screens/backups-partial-job-desktop.png) | [390](ux-ui/screens/backups-partial-job-mobile.png), [320](ux-ui/screens/backups-partial-job-narrow.png) | +| Initial error | [PNG](ux-ui/screens/backups-initial-error-desktop.png) | [390](ux-ui/screens/backups-initial-error-mobile.png), [320](ux-ui/screens/backups-initial-error-narrow.png) | + ## Блок 11А.31.2.1 — ошибки и подтверждения в узких диалогах (2026-10-01) - Delete/restore описания переносят непрерывное имя файла. Предупреждение изменения рабочей конфигурации не обрезается line-clamp на 320 px. Ошибка удаления сохраняет цель; unknown restore оставляет disabled import/«Закрыть», Escape не объявляет отмену RouterOS. diff --git a/docs/ux-ui-stage-11a.md b/docs/ux-ui-stage-11a.md index 2667b289..dea3c7c0 100644 --- a/docs/ux-ui-stage-11a.md +++ b/docs/ux-ui-stage-11a.md @@ -447,3 +447,17 @@ | Ошибка удаления | [PNG](ux-ui/screens/backups-delete-error-desktop.png) | [390](ux-ui/screens/backups-delete-error-mobile.png), [320](ux-ui/screens/backups-delete-error-narrow.png) | | Неизвестный импорт | [PNG](ux-ui/screens/backups-restore-unknown-desktop.png) | [390](ux-ui/screens/backups-restore-unknown-mobile.png), [320](ux-ui/screens/backups-restore-unknown-narrow.png) | + +## Блок 11А.31.2.2 — визуальные состояния S3, partial job и initial error (2026-10-01) + +- Проверены ПК/390/320: S3 отказ доступа с актуальным backend receipt, partial upload при созданной локальной копии, первоначальный отказ истории. Unknown не превращается в empty/нулевые KPI, старая проверка S3 не остаётся успешной после подтверждённой ошибки. Добавлены 9 PNG. +- Просмотр partial job выявил скрытую под Sheet ошибку. Ошибка и «Завершена с ограничениями» теперь показаны внутри формы; общий parent-owned scroll оставляет footer доступным. Результат после reload восстанавливается из terminal checkpoint, не зависит от прежнего состояния hook error. Locked запрос говорит о выбранной цели, не обещает будущее сохранение в текущую конфигурацию хранилища. +- 9 states + 9 job E2E (повтор с reload partial), 3 layout регрессии предыдущим прогоном, 6 polling/result unit, TypeScript/профильный ESLint и production frontend прошли. ReUI audit checklist получен; установленная композиция/semantic tokens/typed API сохранены, клавиатура/scroll/ошибки проверены в профиле. Реальная DB/RouterOS/S3 остаётся внешней приёмкой. +- **Следующий блок 11А.32:** backup scheduler cleanup — lock на всех выходах/setup failure, независимость журнала от подтверждённых файлов, отдельные prune/metadata сбои. Затем 11А.33 профильная локальная приёмка backups; весь 11А ещё не принят. + +| Экран | ПК | Телефон | +| --- | --- | --- | +| S3 ошибка | [PNG](ux-ui/screens/backups-s3-error-desktop.png) | [390](ux-ui/screens/backups-s3-error-mobile.png), [320](ux-ui/screens/backups-s3-error-narrow.png) | +| Partial job | [PNG](ux-ui/screens/backups-partial-job-desktop.png) | [390](ux-ui/screens/backups-partial-job-mobile.png), [320](ux-ui/screens/backups-partial-job-narrow.png) | +| Initial error | [PNG](ux-ui/screens/backups-initial-error-desktop.png) | [390](ux-ui/screens/backups-initial-error-mobile.png), [320](ux-ui/screens/backups-initial-error-narrow.png) | + diff --git a/docs/ux-ui/screens/backups-initial-error-desktop.png b/docs/ux-ui/screens/backups-initial-error-desktop.png new file mode 100644 index 00000000..1f541de6 Binary files /dev/null and b/docs/ux-ui/screens/backups-initial-error-desktop.png differ diff --git a/docs/ux-ui/screens/backups-initial-error-mobile.png b/docs/ux-ui/screens/backups-initial-error-mobile.png new file mode 100644 index 00000000..72071c7a Binary files /dev/null and b/docs/ux-ui/screens/backups-initial-error-mobile.png differ diff --git a/docs/ux-ui/screens/backups-initial-error-narrow.png b/docs/ux-ui/screens/backups-initial-error-narrow.png new file mode 100644 index 00000000..f2fe4c32 Binary files /dev/null and b/docs/ux-ui/screens/backups-initial-error-narrow.png differ diff --git a/docs/ux-ui/screens/backups-partial-job-desktop.png b/docs/ux-ui/screens/backups-partial-job-desktop.png new file mode 100644 index 00000000..91540a08 Binary files /dev/null and b/docs/ux-ui/screens/backups-partial-job-desktop.png differ diff --git a/docs/ux-ui/screens/backups-partial-job-mobile.png b/docs/ux-ui/screens/backups-partial-job-mobile.png new file mode 100644 index 00000000..d9282b91 Binary files /dev/null and b/docs/ux-ui/screens/backups-partial-job-mobile.png differ diff --git a/docs/ux-ui/screens/backups-partial-job-narrow.png b/docs/ux-ui/screens/backups-partial-job-narrow.png new file mode 100644 index 00000000..d898810b Binary files /dev/null and b/docs/ux-ui/screens/backups-partial-job-narrow.png differ diff --git a/docs/ux-ui/screens/backups-s3-error-desktop.png b/docs/ux-ui/screens/backups-s3-error-desktop.png new file mode 100644 index 00000000..b671c54b Binary files /dev/null and b/docs/ux-ui/screens/backups-s3-error-desktop.png differ diff --git a/docs/ux-ui/screens/backups-s3-error-mobile.png b/docs/ux-ui/screens/backups-s3-error-mobile.png new file mode 100644 index 00000000..ebb8acce Binary files /dev/null and b/docs/ux-ui/screens/backups-s3-error-mobile.png differ diff --git a/docs/ux-ui/screens/backups-s3-error-narrow.png b/docs/ux-ui/screens/backups-s3-error-narrow.png new file mode 100644 index 00000000..40cfeb0c Binary files /dev/null and b/docs/ux-ui/screens/backups-s3-error-narrow.png differ diff --git a/lib/backup-job-poll.test.ts b/lib/backup-job-poll.test.ts index b4cb3f7d..d5a309f9 100644 --- a/lib/backup-job-poll.test.ts +++ b/lib/backup-job-poll.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { test } from "node:test" -import { pollBackupJob, backupJobOutcome, waitForBackupPoll } from "./backup-job-poll" +import { pollBackupJob, backupJobOutcome, backupJobResultError, waitForBackupPoll } from "./backup-job-poll" import type { BackupJobStatusResponse } from "@mmapp/contracts/backups" const target = { serverIds: ["1"], notes: "manual" } const baseJob: BackupJobStatusResponse = { id: "job", status: "queued", serverIds: ["1"], notes: "manual", total: 1, completed: 0, requestedAt: "2026-10-01T00:00:00Z", created: [], failures: [] } @@ -30,3 +30,12 @@ test("cancellation interrupts delay and suppresses late observation", async () = await assert.rejects(() => pollBackupJob("base", "job", target, { signal: late.signal, onJob: () => { calls++ }, readJob: async () => { late.abort(); return baseJob } })) assert.equal(calls, 0) }) + +test("saved terminal job reconstructs partial errors without a previous hook error", () => { + assert.equal(backupJobResultError(baseJob), null) + const success: BackupJobStatusResponse = { ...baseJob, status: "done", completed: 1, created: [file] } + assert.equal(backupJobResultError(success), null) + assert.match(backupJobResultError({ ...success, created: [{ ...file, uploadError: "S3 unavailable" }] }) ?? "", /config.rsc: S3 unavailable/) + assert.match(backupJobResultError({ ...success, created: [], failures: [{ serverId: "1", error: "offline" }] }) ?? "", /Создано 0 из 1.*1: offline/) + assert.match(backupJobResultError({ ...baseJob, status: "failed" }) ?? "", /Задача завершилась с ошибкой/) +}) diff --git a/lib/backup-job-poll.ts b/lib/backup-job-poll.ts index 8a247497..a0ab2ae5 100644 --- a/lib/backup-job-poll.ts +++ b/lib/backup-job-poll.ts @@ -27,3 +27,10 @@ export function backupJobOutcome(job: BackupJobStatusResponse) { const uploadFailures = job.created.filter(item => item.uploadError) return { fullSuccess: job.status === "done" && job.created.length === job.total && !job.failures.length && !uploadFailures.length, created: job.created.length, deviceFailures: job.failures, uploadFailures } } + +export function backupJobResultError(job: BackupJobStatusResponse): string | null { + const outcome = backupJobOutcome(job) + if (!["done", "failed"].includes(job.status) || outcome.fullSuccess) return null + const failures = [...outcome.deviceFailures.map(failure => failure.serverId + ": " + failure.error), ...outcome.uploadFailures.map(file => file.filename + ": " + file.uploadError)] + return "Создано " + outcome.created + " из " + job.total + ". " + (failures.join("; ") || "Задача завершилась с ошибкой") +} diff --git a/lib/use-backup-job.ts b/lib/use-backup-job.ts index 18814302..9f35ce57 100644 --- a/lib/use-backup-job.ts +++ b/lib/use-backup-job.ts @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react" import type { CreateBackupRequest } from "@mmapp/contracts/backups" import { ApiClientError } from "@/shared/api/http-client" import { createBackupsAsync } from "@/shared/api/backups" -import { pollBackupJob, backupJobOutcome } from "@/lib/backup-job-poll" +import { pollBackupJob, backupJobOutcome, backupJobResultError } from "@/lib/backup-job-poll" import { backupJobStorageKey, createBackupJobCheckpoint, readBackupJobCheckpoint, type BackupJobCheckpoint } from "@/lib/backup-job-checkpoint" import { userApiError } from "@/shared/api/users" import { toast } from "sonner" @@ -50,10 +50,7 @@ export function useBackupJob(backendUrl: string, live: boolean, canRead: boolean if (!active()) return const outcome = backupJobOutcome(result) if (outcome.fullSuccess) toast.success("API подтвердил создание всех резервных копий", { id: "backup-job-" + result.id }) - else { - const failures = [...outcome.deviceFailures.map(failure => failure.serverId + ": " + failure.error), ...outcome.uploadFailures.map(file => file.filename + ": " + file.uploadError)] - setError("Создано " + outcome.created + " из " + result.total + ". " + (failures.join("; ") || "Задача завершилась с ошибкой")) - } + else setError(backupJobResultError(result)) await onDone() } catch (reason) { if (!active()) return diff --git a/tests/e2e/backups-states-layout.spec.ts b/tests/e2e/backups-states-layout.spec.ts new file mode 100644 index 00000000..bc122962 --- /dev/null +++ b/tests/e2e/backups-states-layout.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "@playwright/test" +import { backup, storage, setupBackups } from "./fixtures/backups" +import { openBackup, backupAck, backupJob } from "./fixtures/backup-job" +for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) { + test("Бэкапы: S3 ошибка и адаптивность/" + name, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); await setupBackups(page) + const s3 = { ...storage, provider: "s3", s3Endpoint: "https://very-long-endpoint-of-backup-storage.example.test", s3Bucket: "long-backup-bucket-name", s3AccessKeyId: "test-access", secretConfigured: true, lastTestAt: "2026-10-01T00:00:00Z" } + await page.route("**/api/backups/storage", r => r.fulfill({ json: s3 })) + await page.route("**/api/backups/storage/test", r => r.fulfill({ status: 400, json: { error: "Доступ к S3 отклонён. Проверьте сохранённые ключи и права на bucket.", settings: { ...s3, lastTestAt: "2026-10-01T01:00:00Z", lastTestError: "Доступ к S3 отклонён. Проверьте сохранённые ключи и права на bucket." } } })) + await page.goto("/backups?tab=settings"); await expect(page.getByText("Проверено ранее", { exact: true })).toBeVisible() + await page.getByRole("button", { name: "Проверить", exact: true }).click(); await expect(page.getByRole("alert", { name: "Операция резервных копий", exact: true })).toContainText("Доступ к S3 отклонён") + await expect(page.getByText("Проверено ранее", { exact: true })).toHaveCount(0); await expect(page.getByText("Доступ к S3 подтверждён", { exact: true })).toHaveCount(0) + await page.getByLabel("Bucket", { exact: true }).scrollIntoViewIfNeeded() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true) + expect(await page.locator("fieldset").evaluateAll(elements => elements.every(element => element.scrollWidth <= element.clientWidth + 1))).toBe(true) + await page.screenshot({ path: "docs/ux-ui/screens/backups-s3-error-" + name + ".png", fullPage: true }) + }) + test("Бэкапы: partial job и адаптивность/" + name, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); await setupBackups(page); let id = "" + await page.route("**/api/backups/create", r => { id = r.request().postDataJSON().requestId; return r.fulfill({ status: 202, json: backupAck(id) }) }) + await page.route("**/api/backups/jobs/*", r => r.fulfill({ json: backupJob(id, { created: [{ ...backup, uploadError: "Загрузка в S3 не подтверждена; локальная копия создана и сохранена." }] }) })) + const dialog = await openBackup(page); await dialog.getByRole("button", { name: /Снять бэкап/ }).click() + await expect(page.getByRole("alert", { name: "Задача резервного копирования", exact: true })).toContainText("Создано 1 из 1") + await expect(dialog.getByRole("alert", { name: "Результат задачи", exact: true })).toContainText("Загрузка в S3 не подтверждена") + await expect(dialog).toContainText("Завершена с ограничениями") + await expect(page.getByText("API подтвердил создание всех резервных копий", { exact: true })).toHaveCount(0); await expect(dialog.getByRole("button", { name: "Новая задача", exact: true })).toBeVisible() + expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true) + await page.screenshot({ path: "docs/ux-ui/screens/backups-partial-job-" + name + ".png", fullPage: true }) + await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0) + await page.reload(); await page.getByRole("button", { name: "Задача резервного копирования", exact: true }).first().click() + await expect(page.getByRole("dialog").getByRole("alert", { name: "Результат задачи", exact: true })).toContainText("Загрузка в S3 не подтверждена") + }) + test("Бэкапы: initial error не превращается в пустую историю/" + name, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); await setupBackups(page) + await page.route("**/api/backups", r => r.fulfill({ status: 503, json: { error: "История недоступна: повторите чтение данных." } })) + await page.goto("/backups"); await expect(page.getByRole("alert", { name: "Данные резервных копий: backups", exact: true })).toContainText("Данные пока неизвестны") + await expect(page.getByText("Нет бэкапов", { exact: true })).toHaveCount(0); await expect(page.getByLabel("Сводка бэкапов", { exact: true })).toContainText("—") + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true) + await page.screenshot({ path: "docs/ux-ui/screens/backups-initial-error-" + name + ".png", fullPage: true }) + }) +}