From 48a81c97e54e24739476d28fa73846edde58caf4 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 10 Oct 2026 16:26:51 +0300 Subject: [PATCH] fix: remove global notice for hidden chat connection --- docs/mobile-smoke-polish/README.md | 2 + docs/mobile-smoke-polish/connection.cjs | 81 ++++++++++++++----- .../mobile-smoke-polish/hidden-chat-notice.md | 44 ++++++++++ .../connection-status-toast.service.spec.ts | 56 ------------- .../connection-status-toast.service.ts | 30 ------- .../src/app/app.component.spec.ts | 28 ++++++- .../social_platform/src/app/app.component.ts | 6 +- 7 files changed, 135 insertions(+), 112 deletions(-) create mode 100644 docs/mobile-smoke-polish/hidden-chat-notice.md delete mode 100644 projects/social_platform/src/app/api/connection-status/connection-status-toast.service.spec.ts delete mode 100644 projects/social_platform/src/app/api/connection-status/connection-status-toast.service.ts diff --git a/docs/mobile-smoke-polish/README.md b/docs/mobile-smoke-polish/README.md index a1e4fdb4a..e5b9f034a 100644 --- a/docs/mobile-smoke-polish/README.md +++ b/docs/mobile-smoke-polish/README.md @@ -2,6 +2,8 @@ # Проверка исправлений после мобильного smoke-test +Этот отчёт и скриншоты фиксируют PR #405 (`09f9bc1d`). После уточнения, что чаты скрыты, глобальное предупреждение о фоновом соединении удалено отдельным исправлением. Актуальный `connection.cjs` проверяет отсутствие этого уведомления и сохранение переподключения/онлайн-статусов; [проверка исправления](hidden-chat-notice.md). Описание уведомления ниже относится к историческому состоянию #405. + Эталон desktop: `origin/dev` на `706a574ed916c6b0a78e99402588ee4889657e74`, после восстановления desktop в #403 и форматирования в #404. Эта проверка подтверждает отсутствие новых desktop-регрессий относительно уже восстановленного dev; сравнение с состоянием до #401 находится в `docs/desktop-regression`. ## Исправления и acceptance criteria diff --git a/docs/mobile-smoke-polish/connection.cjs b/docs/mobile-smoke-polish/connection.cjs index ba78d13bb..cceb90434 100644 --- a/docs/mobile-smoke-polish/connection.cjs +++ b/docs/mobile-smoke-polish/connection.cjs @@ -19,6 +19,19 @@ const directory = path.resolve(process.argv[3] || ".desktop-regression/mobile-sm window.qaChatOffline = true; window.qaChatConnected = false; window.qaChatSockets = []; + window.qaToastCount = 0; + new MutationObserver(records => { + for (const record of records) { + for (const node of record.addedNodes) { + if ( + node.nodeType === 1 && + (node.matches(".snackbar__item") || node.querySelector(".snackbar__item")) + ) { + window.qaToastCount++; + } + } + } + }).observe(document, { childList: true, subtree: true }); window.WebSocket = class { static OPEN = 1; static CONNECTING = 0; @@ -48,37 +61,68 @@ const directory = path.resolve(process.argv[3] || ".desktop-regression/mobile-sm }); const page = harness.page; await page.goto(baseUrl + "/office/feed"); + await page.locator("app-open-vacancy").first().waitFor({ state: "attached" }); const toast = page.locator(".snackbar__item"); - await toast.waitFor({ state: "visible", timeout: 30000 }); - assert.equal(await toast.count(), 1); - assert.match(await toast.innerText(), /Чат временно недоступен/); + // Проходим порог быстрых retry и ещё две попытки; фон продолжает работать без toast. + await page.waitForFunction(() => window.qaChatSockets.length >= 7, null, { timeout: 20000 }); + assert.equal( + await toast.count(), + 0, + "Hidden chat outage must not create a global notification", + ); await page.evaluate(() => document.fonts.ready); - await toast.screenshot({ path: path.join(directory, "unavailable-390.png") }); - await page.getByLabel("Закрыть уведомление").click(); - await toast.waitFor({ state: "hidden" }); - const attempts = await page.evaluate(() => window.qaChatSockets.length); - await page.waitForFunction(attempts => window.qaChatSockets.length > attempts, attempts, { - timeout: 10000, + await page.screenshot({ + path: path.join(directory, "background-outage-390.png"), + fullPage: true, }); - assert.equal(await toast.count(), 0, "Dismissed toast must not reappear during same outage"); await page.evaluate(() => { window.qaChatOffline = false; }); await page.waitForFunction(() => window.qaChatConnected, null, { timeout: 10000 }); assert.equal(await toast.count(), 0); + + await page.evaluate(() => { + history.pushState({}, "", "/office/profile/1"); + dispatchEvent(new PopStateEvent("popstate")); + }); + await page.locator("app-profile-left-side").waitFor({ state: "attached" }); + const onlineBadge = page.locator(".info__avatar .avatar__online"); + assert.equal(await onlineBadge.count(), 0); + const emitStatus = type => + page.evaluate(type => { + window.qaChatSockets.at(-1).onmessage( + new MessageEvent("message", { + data: JSON.stringify({ type, content: { user_id: 1 } }), + }), + ); + }, type); + await emitStatus("set_online"); + await onlineBadge.waitFor({ state: "visible" }); + await emitStatus("set_offline"); + await onlineBadge.waitFor({ state: "hidden" }); + + const attempts = await page.evaluate(() => window.qaChatSockets.length); await page.evaluate(() => { window.qaChatOffline = true; window.qaChatConnected = false; window.qaChatSockets.at(-1).onerror(new Event("error")); }); - await toast.waitFor({ state: "visible", timeout: 30000 }); - assert.equal(await toast.count(), 1, "New outage must produce one new notification"); + await page.waitForFunction(attempts => window.qaChatSockets.length >= attempts + 6, attempts, { + timeout: 20000, + }); + assert.equal(await toast.count(), 0, "Second outage must also stay silent"); await page.evaluate(() => { window.qaChatOffline = false; }); await page.waitForFunction(() => window.qaChatConnected, null, { timeout: 10000 }); - await toast.waitFor({ state: "hidden" }); - assert.equal(await toast.count(), 0, "Reconnect must dismiss notification automatically"); + await emitStatus("set_online"); + await onlineBadge.waitFor({ state: "visible" }); + assert.equal(await toast.count(), 0); + assert.equal( + await page.evaluate(() => window.qaToastCount), + 0, + "No transient toast during any outage", + ); assert.deepEqual(harness.errors, []); fs.writeFileSync( path.join(directory, "result.json"), @@ -87,11 +131,10 @@ const directory = path.resolve(process.argv[3] || ".desktop-regression/mobile-sm width: 390, syntheticWebSocket: true, httpAvailable: true, - oneNotificationPerOutage: true, - manualDismiss: true, - noRedisplayAfterDismiss: true, - secondOutage: true, - autoDismissOnReconnect: true, + noGlobalNotification: true, + automaticReconnect: true, + secondOutageSilent: true, + onlineStatusesAfterReconnect: true, passed: true, }, null, diff --git a/docs/mobile-smoke-polish/hidden-chat-notice.md b/docs/mobile-smoke-polish/hidden-chat-notice.md new file mode 100644 index 000000000..1e4d24c4f --- /dev/null +++ b/docs/mobile-smoke-polish/hidden-chat-notice.md @@ -0,0 +1,44 @@ + + +# Удаление уведомления о фоновом соединении скрытых чатов + +База: `dev` на `c2413ce6`, после merge #405. Чаты скрыты в навигации, но `OfficeInfoService` продолжает подключаться к `/ws/chat/` для обновления онлайн-статусов пользователей. Потеря этого канала не доказывает недоступность HTTP API или остальных разделов платформы. + +## Изменение + +Удалён `ConnectionStatusToastService` и его глобальная инициализация в `AppComponent`. Уведомление о недоступности скрытых чатов больше не появляется. Автоматический retry, очередь сообщений, обновление онлайн-статусов, API-контракты, бизнес-правила, навигация и responsive-стили не изменены. Сервис обычных snackbar и сообщения об ошибках пользовательских операций сохранены. + +## Acceptance criteria и review + +| AC | Требование | Evidence | +| --- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Фоновый сбой не создаёт глобальное предупреждение | AppComponent regression test; browser: первый и второй обрыв, включая retry после порога; MutationObserver подтверждает отсутствие даже кратковременного snackbar | +| 2 | Сохраняются переподключение и онлайн-статусы | WebsocketService tests; browser: после обоих восстановлений `set_online` обновляет badge профиля, `set_offline` его убирает | +| 3 | Обычные ошибки не подавляются | AppComponent test подтверждает доставку другого error snackbar; SnackbarComponent tests; source review: общий обработчик уведомлений и HTTP/API paths не менялись | +| 4 | Desktop/mobile layout сохранён | Source review: HTML/SCSS, компоненты страниц и их раскладка не изменены; единственное исчезновение UI — согласованное предупреждение при фоновом сбое | + +Review: все AC проверены указанным способом, существенных незакрытых замечаний нет. Изменение frontend-only; permissions, миграции данных и backend deployment не затронуты. + +## Проверки + +- Адресные tests: **14 tests / 4 files, exit 0** (`AppComponent`, `WebsocketService`, `ChatRealtimeRepository`, `SnackbarComponent`). +- `npm run build:pr`: exit 0. +- `npm run lint:ts`: exit 0, 0 errors и 6 существующих warnings вне diff. +- Browser на 390 px: синтетический недоступный WebSocket при рабочем HTTP → retry после порога без snackbar → reconnect → online/offline badge → повторный обрыв без snackbar → reconnect и online badge; все assertions прошли, console errors отсутствуют. +- `npm run test:ci`: **2014 tests / 406 files, exit 0**. Три существующих исключения legacy specs в конфигурации Vitest не изменены (`projects.component.spec.ts`, `program.component.spec.ts`, `editor-submit-button.directive.spec.ts`). +- `npm run format:check`: проверка полного tracked-снимка Git index с LF, как в CI; приватные build/QA artifacts исключены из снимка. + +## Воспроизведение browser check + +Собрать текущую ветку через `npm run build:pr`, установить `PLAYWRIGHT_MODULE`/`CHROME_PATH` на доступные Playwright и Chrome. В PowerShell: + +```powershell +$env:QA_BUILD_ROOT = 'dist/social_platform' +node docs/mobile-smoke-polish/connection.cjs http://127.0.0.1:4452 .desktop-regression/hidden-chat-notice/browser +``` + +`preview.cjs` доставляет неизменённые build-файлы напрямую через Playwright route, поэтому локальный HTTP server не нужен. API и WebSocket заменены синтетическими fixtures; live-запросов нет. JSON результата и screenshot находятся в указанном приватном output-каталоге. + +## Не проверено + +Реальные iOS/Android, Safari/WebKit и сетевые события на dev-стенде. В GitHub нет workflow для PR: проверочный deploy workflow запускается по push в dev после merge. На устройствах после merge проверить, что возврат из фона не вызывает глобального предупреждения о чатах. diff --git a/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.spec.ts b/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.spec.ts deleted file mode 100644 index cafd61093..000000000 --- a/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** @format */ - -import { TestBed } from "@angular/core/testing"; -import { BehaviorSubject } from "rxjs"; -import { WebsocketService } from "@corelib"; -import { Snack } from "@domain/shared/snack.model"; -import { SnackbarService } from "@domain/shared/snackbar.service"; -import { ConnectionStatusToastService } from "./connection-status-toast.service"; - -describe("ConnectionStatusToastService", () => { - let status: BehaviorSubject<"idle" | "connected" | "unavailable">; - let snackbar: SnackbarService; - let snacks: Snack[]; - let dismissed: string[]; - - beforeEach(() => { - status = new BehaviorSubject<"idle" | "connected" | "unavailable">("idle"); - TestBed.configureTestingModule({ - providers: [ - { provide: WebsocketService, useValue: { connectionStatus$: status.asObservable() } }, - ], - }); - snackbar = TestBed.inject(SnackbarService); - snacks = []; - dismissed = []; - snackbar.snacks.subscribe(snack => snacks.push(snack)); - snackbar.dismissed$.subscribe(id => dismissed.push(id)); - TestBed.inject(ConnectionStatusToastService); - }); - - it("показывает одно закрываемое сообщение за весь период недоступности", () => { - status.next("connected"); - expect(snacks).toEqual([]); - status.next("unavailable"); - status.next("unavailable"); - status.next("unavailable"); - expect(snacks).toHaveLength(1); - expect(snacks[0]).toMatchObject({ type: "error", timeout: 0, dismissible: true }); - expect(snacks[0].text).toMatch(/Чат временно недоступен/); - }); - - it("убирает сообщение после восстановления и сообщает о следующем отдельном обрыве", () => { - status.next("unavailable"); - status.next("connected"); - expect(dismissed).toEqual([snacks[0].id]); - status.next("unavailable"); - expect(snacks).toHaveLength(2); - expect(snacks[1].id).not.toBe(snacks[0].id); - }); - - it("убирает сообщение при завершении сессии чата", () => { - status.next("unavailable"); - status.next("idle"); - expect(dismissed).toEqual([snacks[0].id]); - }); -}); diff --git a/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.ts b/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.ts deleted file mode 100644 index d1df7a825..000000000 --- a/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** @format */ - -import { inject, Injectable } from "@angular/core"; -import { WebsocketService } from "@corelib"; -import { SnackbarService } from "@domain/shared/snackbar.service"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; - -/** Одно закрываемое уведомление на период недоступности чата; скрывается после восстановления. */ -@Injectable({ providedIn: "root" }) -export class ConnectionStatusToastService { - private readonly websocketService = inject(WebsocketService); - private readonly snackbarService = inject(SnackbarService); - private notificationId?: string; - - constructor() { - this.websocketService.connectionStatus$.pipe(takeUntilDestroyed()).subscribe(status => { - if (status === "unavailable") { - if (!this.notificationId) { - this.notificationId = this.snackbarService.error( - "Чат временно недоступен. Сообщения и статусы могут обновляться с задержкой. Подключаемся автоматически.", - { timeout: 0, dismissible: true }, - ); - } - } else if (this.notificationId) { - this.snackbarService.dismiss(this.notificationId); - this.notificationId = undefined; - } - }); - } -} diff --git a/projects/social_platform/src/app/app.component.spec.ts b/projects/social_platform/src/app/app.component.spec.ts index b825b1092..af8534127 100644 --- a/projects/social_platform/src/app/app.component.spec.ts +++ b/projects/social_platform/src/app/app.component.spec.ts @@ -3,13 +3,18 @@ import { TestBed } from "@angular/core/testing"; import { AppComponent } from "./app.component"; import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; -import { of } from "rxjs"; -import { TokenService } from "@corelib"; +import { BehaviorSubject, of } from "rxjs"; +import { TokenService, WebsocketService } from "@corelib"; import { LoadingService } from "@api/shared/loading.service"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { Snack } from "@domain/shared/snack.model"; import { beforeEach, describe, expect, it, vi } from "vitest"; describe("AppComponent", () => { + let connectionStatus: BehaviorSubject<"idle" | "connected" | "unavailable">; + beforeEach(async () => { + connectionStatus = new BehaviorSubject<"idle" | "connected" | "unavailable">("connected"); const authPortSpy = { fetchProfile: of({} as any), fetchUserRoles: of([]), @@ -30,6 +35,10 @@ describe("AppComponent", () => { { provide: AuthRepositoryPort, useValue: authPortSpy }, { provide: TokenService, useValue: tokenSpy }, { provide: LoadingService, useValue: loadingSpy }, + { + provide: WebsocketService, + useValue: { connectionStatus$: connectionStatus.asObservable() }, + }, ], }).compileComponents(); }); @@ -39,4 +48,19 @@ describe("AppComponent", () => { const app = fixture.componentInstance; expect(app).toBeTruthy(); }); + + it("не уведомляет о фоне скрытых чатов и сохраняет остальные сообщения ошибок", () => { + const snackbar = TestBed.inject(SnackbarService); + const emitted: Snack[] = []; + snackbar.snacks.subscribe(snack => emitted.push(snack)); + TestBed.createComponent(AppComponent); + + connectionStatus.next("unavailable"); + connectionStatus.next("connected"); + connectionStatus.next("unavailable"); + expect(emitted).toEqual([]); + + snackbar.error("Не удалось загрузить программу", { timeout: 0 }); + expect(emitted.map(snack => snack.text)).toEqual(["Не удалось загрузить программу"]); + }); }); diff --git a/projects/social_platform/src/app/app.component.ts b/projects/social_platform/src/app/app.component.ts index c52223ec8..046290a4d 100644 --- a/projects/social_platform/src/app/app.component.ts +++ b/projects/social_platform/src/app/app.component.ts @@ -20,7 +20,6 @@ import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; import { LoadingService } from "@api/shared/loading.service"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { AppRoutes } from "@api/paths/app-routes"; -import { ConnectionStatusToastService } from "@api/connection-status/connection-status-toast.service"; /** * Корневой компонент приложения @@ -44,10 +43,7 @@ export class AppComponent implements OnInit { private tokenService: TokenService, private router: Router, private loadingService: LoadingService, - ) { - // Инстанциируем listener для toast'а при потере WS-соединения. - inject(ConnectionStatusToastService); - } + ) {} ngOnInit(): void { const showLoaderEvents = this.router.events.pipe(