diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md index c0d8e52..af33a18 100644 --- a/COMPLETED_TASKS.md +++ b/COMPLETED_TASKS.md @@ -314,3 +314,57 @@ Документация обновлена; локальные Markdown-ссылки и git diff --check — PASS. - Границы: изменения только evening_detective_screen. Архивы не пересобирались; физический проектор не проверялся. Commit/push не выполнялись. + +## SCREEN-016 — Адрес сервера по умолчанию + +- Статус: `DONE`. +- Дата: 2026-10-18. +- Результат: на чистом профиле подставляется https://evening-detective.crabs-games.art. + Этот известный адрес преобразуется в https://evening-detective-api.crabs-games.art + перед обращением к API. Сохранённый адрес имеет приоритет; остальные адреса, + включая localhost, не преобразуются. Руководство обновлено. +- Проверки: 102 теста, type-check, lint, lint:css и git diff --check — PASS. + Добавлены проверки чистого профиля, входа через адрес сайта, локального адреса + и восстановления сохранённых адресов после перезапуска. +- Границы: изменения только evening_detective_screen. Реальный вход с учётными + данными production не проверялся; установочные архивы не пересобирались. + Commit/push не выполнялись. + +## SCREEN-017 — Повтор статуса подключения после обновления игр + +- Статус: `DONE`. +- Дата: 2026-10-18. +- Результат: по снимку владельца подтверждены пять строк статуса внутри блока + «Игра и сервер». Внешний n-space заменён обычным flex-контейнером: устранена + зависимость условной разметки от flatten и одинаковых ключей обёрток библиотеки. + Поведение API и подключения не менялось. +- Проверки: type-check, lint, lint:css, сборка, git diff --check и Electron smoke — PASS. + В GUI добавлены пять последовательных обновлений с чередованием пустого и + непустого списка игр и проверкой единственной строки статуса. Подключение, + дозагрузка, план и воспроизведение проходят прежние сценарии. +- Ограничение: исходное накопление строк в изолированном профиле до изменения + не воспроизвелось; результат в пользовательском dev-окне требует перезапуска + и подтверждения владельцем. Изменения только evening_detective_screen; + архивы не пересобирались, commit/push не выполнялись. + +## SCREEN-018 — Причина ошибки подключения игры + +- Статус: `DONE`. +- Дата: 2026-10-18. +- Результат: ошибки авторизации и смены сессии при подготовке игры больше + не заменяются сообщением «Сервер недоступен». Сетевые ошибки называют запрос + (описание игры, план экранов, музыка), различают отмену, таймаут, DNS, + отказ/сброс соединения и ошибки сертификата. Неизвестные сообщения исключений + не отображаются и не журналируются. +- В диагностический журнал добавлено событие request-error: только разрешённый + шаблон пути и код причины, без URL, ID игры, токенов и содержимого ответа. + Общие типы транспортных ошибок используются загрузчиком описания и Online. +- Проверки: 107 тестов, type-check, lint, lint:css, сборка, Electron GUI smoke, + 33 локальные Markdown-ссылки и git diff --check — PASS. Проверены ошибки + каждого запроса, отмена, таймаут, истёкшая авторизация, смена сессии и журнал + без секретов. Вход через Online в http://127.0.0.1:8090 успешен; последующий + список игр возвращает прежний HTTP 500 из-за устаревшей локальной схемы БД. +- Ограничения: причина исходного сетевого сбоя публичного API пока не установлена; + новая диагностика предназначена для следующего воспроизведения. Изменения + только evening_detective_screen, сервер и схема БД не менялись. Установочные + архивы не пересобирались; commit/push не выполнялись. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 926fa66..d6169d5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -79,6 +79,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова принудительной загрузки закройте показ и нажмите «Загрузить материалы». Ошибка или отмена ручного обновления сохраняет прежний полный пакет. +На чистом профиле адрес сервера заполнен: `https://evening-detective.crabs-games.art`. +Приложение направляет запросы этого сайта на `https://evening-detective-api.crabs-games.art`. +Сохранённый адрес имеет приоритет; для локального сервера можно вручную указать +`http://127.0.0.1:8090`. + ## Проведение показа В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг» @@ -232,3 +237,9 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова Разработчикам: [README](../README.md). Проверяющим: [сборки и полная приёмка](SCREEN-008_ACCEPTANCE.md). + +При ошибке подключения игры сообщение указывает запрос: описание игры, план +экранов или музыка. Таймаут, отмена, ошибка адреса и отказ соединения различаются. +Ошибка сессии требует повторного входа, а не проверки сети. В `diagnostic.jsonl` +событие `request-error` содержит только шаблон пути и разрешённый код причины; +пароли, токены, содержимое ответа и полные адреса туда не записываются. diff --git a/electron/diagnostics.test.ts b/electron/diagnostics.test.ts index 01be09c..9201614 100644 --- a/electron/diagnostics.test.ts +++ b/electron/diagnostics.test.ts @@ -19,13 +19,21 @@ test('журнал сохраняет только разрешённые пол log.record('output-close', { reason: 'renderer', password: 'SECRET', error: new Error('SECRET') }) log.record('output-close', { reason: 'https://secret.example/SECRET' }) log.record('package', { action: 'download', status: 'failed', response: 'SECRET' }) + log.record('request-error', { + path: '/api/games/{id}/music', + reason: 'ENOTFOUND', + token: 'SECRET', + error: new Error('SECRET'), + }) + log.record('request-error', { path: '/api/games/7?token=SECRET', reason: 'ENOTFOUND' }) + log.record('request-error', { path: '/api/games/{id}', reason: 'SECRET' }) const content = await readFile(join(dir, 'diagnostic.jsonl'), 'utf8') assert.equal(content.includes('SECRET'), false) const lines = content .trim() .split('\n') .map((s) => JSON.parse(s)) - assert.equal(lines.length, 3) + assert.equal(lines.length, 4) assert.equal(lines[0].version, '0.2.0-rc.1') assert.deepEqual(Object.keys(lines[1]).sort(), ['event', 'reason', 'time']) }) diff --git a/electron/diagnostics.ts b/electron/diagnostics.ts index a1aeb62..08f6dfe 100644 --- a/electron/diagnostics.ts +++ b/electron/diagnostics.ts @@ -1,3 +1,4 @@ +import { NETWORK_ERROR_CODES } from './request-error.js' import { appendFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs' import { join } from 'node:path' @@ -12,6 +13,7 @@ type Event = | 'media-error' | 'connection' | 'package' + | 'request-error' const fields: Partial>> = { 'output-close': { reason: [ @@ -25,6 +27,10 @@ const fields: Partial>> = { 'unresponsive', ], }, + 'request-error': { + path: ['/api/games/{id}', '/api/games/{id}/screen-plan', '/api/games/{id}/music'], + reason: [...NETWORK_ERROR_CODES, 'AUTH', 'HTTP', 'INVALID_JSON', 'SESSION_CHANGED'], + }, 'media-error': { channel: ['main', 'music', 'scene'] }, connection: { status: ['disabled', 'waiting', 'connected', 'offline', 'expired'] }, package: { action: ['download', 'check', 'remove'], status: ['success', 'failed'] }, @@ -40,6 +46,7 @@ const events: readonly string[] = [ 'media-error', 'connection', 'package', + 'request-error', ] export class DiagnosticLog { private disabled = false diff --git a/electron/main.ts b/electron/main.ts index 5f493d3..06ff8f8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -290,14 +290,21 @@ app } const packages = new PackageStore(join(app.getPath('userData'), 'packages')) await packages.initialize() - online = new Online(join(app.getPath('userData'), 'connection'), packages, controller, { - available: () => - safeStorage.isEncryptionAvailable() && - (process.platform !== 'linux' || - !['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())), - encrypt: (value) => safeStorage.encryptString(value), - decrypt: (value) => safeStorage.decryptString(value), - }) + online = new Online( + join(app.getPath('userData'), 'connection'), + packages, + controller, + { + available: () => + safeStorage.isEncryptionAvailable() && + (process.platform !== 'linux' || + !['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())), + encrypt: (value) => safeStorage.encryptString(value), + decrypt: (value) => safeStorage.decryptString(value), + }, + undefined, + (failure) => diagnostics?.record('request-error', failure), + ) await online.initialize() let previousConnection = online.state.connection const logConnection = () => { diff --git a/electron/online.test.ts b/electron/online.test.ts index 8d93a24..b4a28a7 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -35,6 +35,7 @@ async function fixture(t: TestContext, secure = true) { } const model = { offline: false, + server: 'https://server.test', introURL: 'https://media.test/intro.wav', musicOverride: null as Record | null, mediaHandler: null as ((url: string) => Promise) | null, @@ -82,7 +83,7 @@ async function fixture(t: TestContext, secure = true) { headers: { 'Content-Type': 'audio/wav' }, }) } - assert.equal(url.origin, 'https://server.test') + assert.equal(url.origin, model.server) if (path === '/api/auth/login') return json({ accessToken: 'ACCESS-SECRET', refreshToken: 'REFRESH-SECRET' }) if (path === '/api/auth/refresh') { @@ -264,6 +265,44 @@ async function fixture(t: TestContext, secure = true) { prepare, } } +test('чистый профиль предлагает адрес сайта по умолчанию', async (t) => { + const f = await fixture(t) + assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art') +}) + +for (const [input, expected] of [ + ['https://evening-detective.crabs-games.art/', 'https://evening-detective-api.crabs-games.art'], + ['http://127.0.0.1:8090', 'http://127.0.0.1:8090'], +]) { + test(`адрес входа ${input}: API и сохранённый адрес ${expected}`, async (t) => { + const f = await fixture(t, false) + f.model.server = expected! + await f.online.act({ + action: 'login', + server: input!, + email: 'test@example.test', + password: 'PASSWORD-SECRET', + }) + assert.equal(f.online.snapshot().authenticated, true) + assert.equal(f.online.snapshot().server, expected) + assert.ok(f.requests.some((r) => r.path === '/api/auth/login')) + await f.online.close() + const restored = new Online( + join(f.directory, 'connection'), + f.store, + new Controller(() => {}), + f.secrets, + f.fetchAPI, + ) + try { + await restored.initialize() + assert.equal(restored.snapshot().server, expected) + } finally { + await restored.close() + } + }) +} + function objectMusic(version: number, index = 0) { return { stateVersion: String(version), @@ -1189,3 +1228,12 @@ test('публикация текста после смены игры не от assert.equal(f.controller.state.catalog.key, null) assert.equal(f.controller.state.scene.stepIndex, null) }) + +test('истёкшая авторизация при подключении игры не маскируется сетевой ошибкой', async (t) => { + const f = await fixture(t) + await f.login() + f.model.accessExpired = true + f.model.refreshExpired = true + await assert.rejects(f.online.act({ action: 'connect', gameId: 7 }), /Сессия истекла/) + assert.equal(f.online.snapshot().authenticated, false) +}) diff --git a/electron/online.ts b/electron/online.ts index ffbae09..5017d76 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -1,6 +1,7 @@ +import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js' import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import type { OnlineIntent, OnlineState } from '../src/types/online.js' +import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../src/types/online.js' import type { Intent, PlanStatus, @@ -39,20 +40,6 @@ interface Credentials { refresh: string screen: ScreenSession | null } -class NetworkError extends Error {} -class ApiError extends Error { - constructor(public status: number) { - super( - status === 401 - ? 'Сессия истекла. Войдите снова' - : status === 403 - ? 'Нет доступа к действию' - : status === 400 - ? 'Действие пока недоступно для этой игры' - : `Ошибка сервера: HTTP ${status}`, - ) - } -} function serverURL(value: unknown): string { const url = new URL(text(value)) if ( @@ -64,7 +51,9 @@ function serverURL(value: unknown): string { (url.pathname !== '/' && url.pathname !== '') ) throw new Error('Укажите адрес HTTP(S) сервера без пути и параметров') - return url.origin + return url.origin === DEFAULT_SERVER + ? 'https://evening-detective-api.crabs-games.art' + : url.origin } function token(value: unknown): string { const s = text(value) @@ -74,7 +63,7 @@ function token(value: unknown): string { } export class Online { readonly state: OnlineState = { - server: '', + server: DEFAULT_SERVER, authenticated: false, remembered: false, busy: false, @@ -119,6 +108,7 @@ export class Online { gameId, token: this.credentials.access, signal: this.materialAbort.signal, + onRequestFailure: this.onRequestFailure, }, (url, init) => { const parsed = new URL(String(url)) @@ -489,7 +479,7 @@ export class Online { body: JSON.stringify(plan), }), ) - if (generation !== this.generation) throw new Error('Сессия изменилась') + if (generation !== this.generation) throw new SessionChangedError() await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) } finally { this.planBusy-- @@ -839,6 +829,9 @@ export class Online { private controller: Controller, private secrets: SecretStorage, private fetchAPI: typeof fetch = fetch, + private onRequestFailure: NonNullable< + import('./screen-package.js').PackageRequest['onRequestFailure'] + > = () => {}, ) { controller.onMusicEnded = (report) => { if (!this.screen) return false @@ -1000,18 +993,19 @@ export class Online { this.state.connection = 'disabled' } private async raw(server: string, path: string, init: RequestInit = {}): Promise { + const signal = AbortSignal.any([ + AbortSignal.timeout(30_000), + ...(init.signal ? [init.signal] : []), + ]) try { return await this.fetchAPI(`${server}${path}`, { ...init, redirect: 'error', credentials: 'omit', - signal: AbortSignal.any([ - AbortSignal.timeout(30_000), - ...(init.signal ? [init.signal] : []), - ]), + signal, }) - } catch { - throw new NetworkError('Сервер недоступен или превышено время ожидания') + } catch (error) { + throw networkFailure(error, signal) } } private async json(response: Response): Promise> { @@ -1032,7 +1026,7 @@ export class Online { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: c.refresh }), }) - if (generation !== this.generation) throw new Error('Сессия изменилась') + if (generation !== this.generation) throw new SessionChangedError() if (response.status === 401) { this.credentials = null this.state.authenticated = false @@ -1040,7 +1034,7 @@ export class Online { throw new ApiError(401) } const result = await this.json(response) - if (generation !== this.generation) throw new Error('Сессия изменилась') + if (generation !== this.generation) throw new SessionChangedError() this.credentials = { ...c, access: token(result.accessToken), @@ -1062,7 +1056,7 @@ export class Online { return this.raw(c.server, path, { ...init, headers }) } let response = await request(c.access) - if (generation !== this.generation) throw new Error('Сессия изменилась') + if (generation !== this.generation) throw new SessionChangedError() if (response.status === 401) { await response.body?.cancel() await this.refresh(c.access, generation) @@ -1195,6 +1189,7 @@ export class Online { gameId, token: this.credentials.access, signal: this.cancellation.signal, + onRequestFailure: this.onRequestFailure, }, (url, init) => { const parsed = new URL(String(url)) diff --git a/electron/request-error.test.ts b/electron/request-error.test.ts new file mode 100644 index 0000000..0fc7098 --- /dev/null +++ b/electron/request-error.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { networkFailure } from './request-error.js' + +test('безопасная причина соединения извлекается из вложенных Node-ошибок', () => { + for (const [code, message] of [ + ['ECONNREFUSED', 'Сервер отклонил соединение'], + ['ECONNRESET', 'Соединение с сервером прервано'], + ['UND_ERR_CONNECT_TIMEOUT', 'Превышено время ожидания соединения с сервером'], + ['CERT_HAS_EXPIRED', 'Ошибка защищённого соединения с сервером'], + ['SECRET', 'Сервер недоступен'], + ]) { + const error = new TypeError('SECRET', { + cause: new AggregateError([Object.assign(new Error('SECRET'), { code })]), + }) + const result = networkFailure(error, new AbortController().signal) + assert.equal(result.message, message) + assert.equal(result.code, code === 'SECRET' ? 'NETWORK' : code) + assert.ok(!result.message.includes('SECRET')) + } +}) diff --git a/electron/request-error.ts b/electron/request-error.ts new file mode 100644 index 0000000..07e3d22 --- /dev/null +++ b/electron/request-error.ts @@ -0,0 +1,77 @@ +export const NETWORK_ERROR_CODES = [ + 'NETWORK', + 'TIMEOUT', + 'ABORTED', + 'ENOTFOUND', + 'EAI_AGAIN', + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'UND_ERR_CONNECT_TIMEOUT', + 'CERT_HAS_EXPIRED', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'ERR_TLS_CERT_ALTNAME_INVALID', +] as const +export type NetworkErrorCode = (typeof NETWORK_ERROR_CODES)[number] +export class NetworkError extends Error { + constructor( + message: string, + public code: NetworkErrorCode = 'NETWORK', + ) { + super(message) + } +} +export class SessionChangedError extends Error { + constructor() { + super('Сессия изменилась. Повторите подключение игры') + } +} +export class ApiError extends Error { + constructor(public status: number) { + super( + status === 401 + ? 'Сессия истекла. Войдите снова' + : status === 403 + ? 'Нет доступа к действию' + : status === 400 + ? 'Действие пока недоступно для этой игры' + : `Ошибка сервера: HTTP ${status}`, + ) + } +} + +function causeCode(error: unknown, depth = 0): NetworkErrorCode | undefined { + if (!error || typeof error !== 'object' || depth > 3) return + const value = error as { code?: unknown; cause?: unknown; errors?: unknown[] } + if (NETWORK_ERROR_CODES.includes(value.code as NetworkErrorCode)) + return value.code as NetworkErrorCode + return ( + causeCode(value.cause, depth + 1) ?? + (Array.isArray(value.errors) + ? value.errors.map((e) => causeCode(e, depth + 1)).find(Boolean) + : undefined) + ) +} +export function networkFailure(error: unknown, signal: AbortSignal): NetworkError { + if (signal.aborted) { + return signal.reason?.name === 'TimeoutError' + ? new NetworkError('Превышено время ожидания ответа сервера', 'TIMEOUT') + : new NetworkError('Подготовка отменена', 'ABORTED') + } + if (error instanceof NetworkError) return error + const code = causeCode(error) ?? 'NETWORK' + const message = + code === 'ENOTFOUND' || code === 'EAI_AGAIN' + ? 'Не удалось определить адрес сервера' + : code.includes('CERT') || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' + ? 'Ошибка защищённого соединения с сервером' + : code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' + ? 'Превышено время ожидания соединения с сервером' + : code === 'ECONNREFUSED' + ? 'Сервер отклонил соединение' + : code === 'ECONNRESET' + ? 'Соединение с сервером прервано' + : 'Сервер недоступен' + return new NetworkError(message, code) +} diff --git a/electron/screen-package.test.ts b/electron/screen-package.test.ts index c4d83f1..65e9cb9 100644 --- a/electron/screen-package.test.ts +++ b/electron/screen-package.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import { test } from 'node:test' +import { ApiError, SessionChangedError } from './request-error.js' import { buildScreenPackage, prepareScreenPackage } from './screen-package.js' const server = 'https://example.test' @@ -200,7 +201,7 @@ test('отказы API и некорректный JSON не выдают час prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async () => { throw new Error('SECRET') }), - /^Error: Сервер недоступен$/, + /^Error: Описание игры: Сервер недоступен$/, ) }) @@ -236,3 +237,69 @@ test('отмена и недопустимые адреса обрабатыва ) await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: '' }), /токен/) }) + +test('сетевая ошибка называет запрос и журналирует только безопасную причину', async () => { + for (const suffix of ['', '/screen-plan', '/music']) { + const failures: { path: string; reason: string }[] = [] + const data = fixture() + await assert.rejects( + prepareScreenPackage( + { server, gameId: 7, token: 'SECRET', onRequestFailure: (f) => failures.push(f) }, + async (input) => { + const url = String(input) + if (url === `${server}/api/games/7${suffix}`) + throw new TypeError('SECRET', { + cause: Object.assign(new Error('SECRET'), { code: 'ENOTFOUND' }), + }) + return Response.json( + url.endsWith('/music') + ? data.music + : url.endsWith('/screen-plan') + ? data.plan + : data.game, + ) + }, + ), + new RegExp( + `^Error: ${suffix === '/music' ? 'Музыка игры' : suffix === '/screen-plan' ? 'План экранов' : 'Описание игры'}: Не удалось определить адрес сервера$`, + ), + ) + assert.deepEqual(failures, [{ path: `/api/games/{id}${suffix}`, reason: 'ENOTFOUND' }]) + assert.ok(!JSON.stringify(failures).includes('SECRET')) + } +}) + +test('подготовка сохраняет ошибки авторизации и смены сессии', async () => { + for (const error of [new ApiError(401), new ApiError(403), new SessionChangedError()]) { + const data = fixture() + await assert.rejects( + prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) => { + const url = String(input) + if (url.endsWith('/screen-plan')) throw error + return Response.json(url.endsWith('/music') ? data.music : data.game) + }), + (e) => e === error, + ) + } +}) + +test('таймаут и отмена подготовки различаются без утечки причины', async () => { + for (const [name, message, reason] of [ + ['TimeoutError', 'Превышено время ожидания ответа сервера', 'TIMEOUT'], + ['AbortError', 'Подготовка отменена', 'ABORTED'], + ]) { + const signal = AbortSignal.abort(new DOMException('SECRET', name)) + const failures: { reason: string }[] = [] + await assert.rejects( + prepareScreenPackage( + { server, gameId: 7, token: 'SECRET', signal, onRequestFailure: (f) => failures.push(f) }, + async (_input, init) => { + init?.signal?.throwIfAborted() + return Response.json({}) + }, + ), + new RegExp(`^Error: Описание игры: ${message}$`), + ) + assert.ok(failures.every((f) => f.reason === reason)) + } +}) diff --git a/electron/screen-package.ts b/electron/screen-package.ts index 17c993f..ef3583a 100644 --- a/electron/screen-package.ts +++ b/electron/screen-package.ts @@ -1,3 +1,10 @@ +import { + ApiError, + NetworkError, + SessionChangedError, + networkFailure, + type NetworkErrorCode, +} from './request-error.js' import { createHash } from 'node:crypto' export interface ScreenPackage { @@ -30,6 +37,10 @@ export interface PackageRequest { gameId: number token: string signal?: AbortSignal + onRequestFailure?: (failure: { + path: string + reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON' + }) => void } function object(value: unknown): Record { @@ -238,12 +249,35 @@ export async function prepareScreenPackage( redirect: 'error', signal, }) - } catch { - throw new Error( - signal.aborted ? 'Подготовка отменена или превышено время ожидания' : 'Сервер недоступен', - ) + } catch (error) { + const path = `/api/games/{id}${suffix}` + if (error instanceof ApiError || error instanceof SessionChangedError) { + request.onRequestFailure?.({ + path, + reason: + error instanceof ApiError + ? [401, 403].includes(error.status) + ? 'AUTH' + : 'HTTP' + : 'SESSION_CHANGED', + }) + throw error + } + const failure = networkFailure(error, signal) + request.onRequestFailure?.({ path, reason: failure.code }) + const name = + suffix === '/music' + ? 'Музыка игры' + : suffix === '/screen-plan' + ? 'План экранов' + : 'Описание игры' + throw new NetworkError(`${name}: ${failure.message}`, failure.code) } - if (!response.ok) + if (!response.ok) { + request.onRequestFailure?.({ + path: `/api/games/{id}${suffix}`, + reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP', + }) throw new Error( response.status === 401 ? 'Требуется авторизация' @@ -251,9 +285,11 @@ export async function prepareScreenPackage( ? 'Нет доступа к игре' : `Ошибка API: HTTP ${response.status}`, ) + } try { return await response.json() } catch { + request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' }) throw new Error('Некорректный JSON в ответе API') } } diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index 080e8b7..7857287 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -12,6 +12,8 @@ module.exports = async function onlineSmoke({ displayId, root, }) { + let gamesCalls = 0 + let varyGames = false let version = 1 let offline = false let polls = 0 @@ -47,8 +49,15 @@ module.exports = async function onlineSmoke({ return send({ accessToken: 'TEST-ACCESS', refreshToken: 'TEST-REFRESH' }) } if (path === '/api/users/me') return send({ user: { roles: ['organizer'] } }) - if (path === '/api/games') - return send({ games: [{ ID: 6, name: 'HTTP smoke', status: 'finish' }] }) + if (path === '/api/games') { + gamesCalls++ + return send({ + games: + varyGames && gamesCalls % 2 === 0 + ? [] + : [{ ID: 6, name: 'HTTP smoke', status: 'finish' }], + }) + } if (path === '/api/games/6') return send({ game: { @@ -173,6 +182,48 @@ module.exports = async function onlineSmoke({ ), 'server game selector', ) + varyGames = true + for (let refresh = 0; refresh < 5; refresh++) { + await poll( + () => + evaluate( + `!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Обновить список игр')?.disabled`, + ), + 'refresh games button ready', + ) + const previousCalls = gamesCalls + await evaluate( + `Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Обновить список игр').click()`, + ) + await poll( + async () => + gamesCalls > previousCalls && !(await evaluate('window.screenBridge.onlineState()')).busy, + 'refresh games complete', + ) + await poll( + () => + evaluate( + `document.querySelector('.connection-content')?.textContent.includes('Управление не подключено')`, + ), + 'connection status rendered', + ) + assert.equal( + await evaluate( + `document.querySelector('.connection-content').textContent.split('Управление не подключено').length - 1`, + ), + 1, + 'refresh does not duplicate status', + ) + } + await poll( + () => + evaluate( + `!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Обновить список игр')?.disabled`, + ), + 'last refresh complete', + ) + varyGames = false + await act({ action: 'games' }) await evaluate( `Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру для подготовки')).click()`, ) diff --git a/src/components/ConnectionPanel.vue b/src/components/ConnectionPanel.vue index ca1c8d4..cfe6c10 100644 --- a/src/components/ConnectionPanel.vue +++ b/src/components/ConnectionPanel.vue @@ -1,14 +1,14 @@