From 7a97b9b506ac3970c7572dea9ba05dace6235370 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 18 Oct 2026 04:51:12 +0700 Subject: [PATCH] up --- COMPLETED_TASKS.md | 23 +++ docs/USER_GUIDE.md | 6 + electron/online.test.ts | 18 +++ electron/online.ts | 23 ++- electron/remote-state.ts | 208 ++++++++++++++++--------- electron/request-error.test.ts | 16 +- electron/request-error.ts | 69 +++++++++ electron/screen-package.test.ts | 69 ++++++++- electron/screen-package.ts | 237 +++++++++++++++++++---------- electron/screen-plan.ts | 71 ++++++--- scripts/online-smoke.cjs | 25 ++- src/components/ConnectionPanel.vue | 15 +- src/components/ScreenPlanPanel.vue | 6 +- 13 files changed, 604 insertions(+), 182 deletions(-) diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md index 4b17333..e96acc9 100644 --- a/COMPLETED_TASKS.md +++ b/COMPLETED_TASKS.md @@ -395,3 +395,26 @@ границы JSON; напрямую трактовать их как number нельзя. Ограничение описано в README. - Границы: изменения только evening_detective_screen. Сервер, frontend и proto не менялись, установочные архивы не пересобирались; commit/push не выполнялись. + +## SCREEN-020 — Подробные ошибки API в интерфейсе + +- Статус: `DONE`. +- Дата: 2026-10-18. +- Результат: существующие сообщения ошибок показывают операцию, метод и путь API, + HTTP-статус или разрешённый код сети. Ошибки описания игры, плана, музыки и + состояния экрана указывают путь поля с индексами, ожидаемый и фактический тип. + Правила обязательности и допустимые значения полей сохранены. +- Контекст запроса сохраняет типы ошибок авторизации/сети/смены сессии; повторное + добавление префикса предотвращено. Ошибки доступны через существующие поля + состояния, в том числе в фоновой подготовке. Длинные строки переносятся в + блоке подключения и предупреждении плана. Значения полей, тела ответов, + query-параметры, пароли, токены и stack trace в интерфейс не передаются. +- Проверки: 113 тестов, type-check, lint, lint:css, сборка, Electron GUI smoke, + 33 локальные Markdown-ссылки и git diff --check — PASS. Добавлены проверки + отсутствующего/null/массивного/строкового поля истории, индексов плана и треков, + HTTP-статуса, сетевых кодов, состояния экрана и отсутствия секретов. GUI + проверяет подробности ошибки истории, перенос текста и успешное подключение + после исправления тестового ответа. +- Границы: изменения только evening_detective_screen. Сервер и сгенерированный + клиент не менялись. Production-ошибка не воспроизводилась; установочные + архивы не пересобирались, commit/push не выполнялись. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d6169d5..df0aec5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -243,3 +243,9 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова Ошибка сессии требует повторного входа, а не проверки сети. В `diagnostic.jsonl` событие `request-error` содержит только шаблон пути и разрешённый код причины; пароли, токены, содержимое ответа и полные адреса туда не записываются. + +Подробности ошибки видны прямо в пульте: операция, метод и путь API, статус HTTP +или безопасный код сетевого сбоя. Если JSON имеет неверную структуру, сообщение +показывает поле (включая индекс элемента), ожидаемый и фактический тип, например: +`Описание игры · GET /api/games/7: Некорректный ответ API: поле game.scenario.story — ожидалось объект, получено null`. +Содержимое ответа, значения полей, токены и stack trace не отображаются. diff --git a/electron/online.test.ts b/electron/online.test.ts index 9abab47..242602d 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -1237,3 +1237,21 @@ test('истёкшая авторизация при подключении иг await assert.rejects(f.online.act({ action: 'connect', gameId: 7 }), /Сессия истекла/) assert.equal(f.online.snapshot().authenticated, false) }) + +test('некорректное серверное состояние отображает запрос и поле без содержимого ответа', async (t) => { + const f = await fixture(t) + await f.prepare() + f.model.stateResponse = async () => + Response.json({ + state: { + ...f.model.remote, + music: { playlist: { tracks: [{ ID: { password: 'SECRET' } }] } }, + }, + }) + await f.online.poll() + const message = f.online.snapshot().error + assert.match(message, /Состояние экрана · POST \/api\/screens\/state/) + assert.ok(message.includes('state.music.playlist.tracks[0].ID')) + assert.ok(message.includes('получено object')) + assert.ok(!message.includes('SECRET')) +}) diff --git a/electron/online.ts b/electron/online.ts index b5b1401..f758f8e 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -1,3 +1,4 @@ +import { requestErrorContext } from './request-error.js' import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js' import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js' import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js' @@ -989,11 +990,13 @@ export class Online { } private async json(response: Response): Promise> { if (!response.ok) throw new ApiError(response.status) + let value: unknown try { - return object(await response.json()) + value = await response.json() } catch { throw new Error('Сервер вернул некорректный JSON') } + return object(value, 'response') } private async refresh(failed: string, generation: number) { if (this.credentials?.access !== failed) return @@ -1049,7 +1052,7 @@ export class Online { return response } private client(authorized = true, signal?: AbortSignal, server = this.state.server) { - return createEveningDetectiveServerClient(async (request) => { + return createEveningDetectiveServerClient(async (request, meta) => { const init: RequestInit = { method: request.method, headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, @@ -1057,9 +1060,19 @@ export class Online { signal, } const path = `/${request.path}` - return this.json( - authorized ? await this.authorized(path, init) : await this.raw(server, path, init), - ) + try { + const result = await this.json( + authorized ? await this.authorized(path, init) : await this.raw(server, path, init), + ) + // Проверяем JSON до выхода из транспорта, пока доступен контекст запроса. + if (meta.method === 'GetScreenState') parseRemote(result.state) + if (meta.method === 'AdvanceScreenMusic') parseRemote({ music: result.state }) + if (['GetScreenPlan', 'SaveScreenPlan', 'SetScreenPlanStep'].includes(meta.method)) + parsePlan(result.plan) + return result + } catch (error) { + throw requestErrorContext(error, meta.method, request.method, path) + } }) } async act(input: unknown): Promise { diff --git a/electron/remote-state.ts b/electron/remote-state.ts index 426e96e..5ac7a47 100644 --- a/electron/remote-state.ts +++ b/electron/remote-state.ts @@ -1,34 +1,58 @@ +import { fieldError, ResponseFieldError } from './request-error.js' import type { Controller } from './controller.js' import type { Intent, ScreenMode } from '../src/types/screen.js' -export function object(value: unknown): Record { +export function object(value: unknown, field?: string): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) - throw new Error('Некорректный ответ сервера') + throw fieldError(field, 'объект', value, 'Некорректный ответ сервера') return value as Record } -export function integer(value: unknown, min = 0, max = Number.MAX_SAFE_INTEGER): number { +export function integer( + value: unknown, + min = 0, + max = Number.MAX_SAFE_INTEGER, + field?: string, +): number { const n = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value if (typeof n !== 'number' || !Number.isSafeInteger(n) || n < min || n > max) - throw new Error('Некорректное число в ответе сервера') + throw fieldError( + field, + `целое число от ${min} до ${max}`, + value, + 'Некорректное число в ответе сервера', + ) return n } -export function text(value: unknown): string { - if (typeof value !== 'string') throw new Error('Некорректный текст в ответе сервера') +export function text(value: unknown, field?: string): string { + if (typeof value !== 'string') + throw fieldError(field, 'строка', value, 'Некорректный текст в ответе сервера') return value } -function enumeration(value: unknown, prefix: string, names: string[]): number { +function enumeration(value: unknown, prefix: string, names: string[], field?: string): number { const n = typeof value === 'string' && value.startsWith(prefix) ? names.indexOf(value.slice(prefix.length)) - : integer(value ?? 0) - return integer(n, 0, names.length - 1) + : integer(value ?? 0, 0, Number.MAX_SAFE_INTEGER, field) + if (n < 0 || n >= names.length) + throw fieldError(field, `enum ${prefix}`, value, 'Некорректный enum в ответе сервера') + return integer(n, 0, names.length - 1, field) } -function version(value: unknown): bigint { +function version(value: unknown, field: string): bigint { if (typeof value === 'number' && !Number.isSafeInteger(value)) - throw new Error('Некорректная версия состояния') + throw new ResponseFieldError( + field, + 'неотрицательный int64 без потери точности', + value, + 'Некорректная версия состояния', + ) const v = String(value ?? '0') if (!/^\d{1,19}$/.test(v) || BigInt(v) > 9223372036854775807n) - throw new Error('Некорректная версия состояния') + throw new ResponseFieldError( + field, + 'неотрицательный int64 без потери точности', + value, + 'Некорректная версия состояния', + ) return BigInt(v) } export interface RemoteState { @@ -60,87 +84,123 @@ export interface RemoteState { } | null } export function parseRemote(value: unknown): RemoteState { - const s = object(value) + const s = object(value, 'state') const mode = - enumeration(s.mode, 'SCREEN_MODE_', [ - 'UNSPECIFIED', - 'COVER', - 'INTRO', - 'INTRO_DONE', - 'CONCLUSION', - 'CONCLUSION_DONE', - 'RESULTS', - 'TIMER', - 'TEXT', - ]) || 1 - const duration = integer(s.timerDurationSeconds ?? 600, 1, 86400) - const remaining = integer(s.timerRemainingSeconds ?? 0, 0, duration) - const timerState = enumeration(s.timerState, 'SCREEN_TIMER_STATE_', [ - 'UNSPECIFIED', - 'STOPPED', - 'RUNNING', - 'PAUSED', - 'FINISHED', - ]) + enumeration( + s.mode, + 'SCREEN_MODE_', + [ + 'UNSPECIFIED', + 'COVER', + 'INTRO', + 'INTRO_DONE', + 'CONCLUSION', + 'CONCLUSION_DONE', + 'RESULTS', + 'TIMER', + 'TEXT', + ], + 'state.mode', + ) || 1 + const duration = integer(s.timerDurationSeconds ?? 600, 1, 86400, 'state.timerDurationSeconds') + const remaining = integer( + s.timerRemainingSeconds ?? 0, + 0, + duration, + 'state.timerRemainingSeconds', + ) + const timerState = enumeration( + s.timerState, + 'SCREEN_TIMER_STATE_', + ['UNSPECIFIED', 'STOPPED', 'RUNNING', 'PAUSED', 'FINISHED'], + 'state.timerState', + ) let music: RemoteState['music'] = null if (s.music) { - const m = object(s.music) - const p = m.playlist ? object(m.playlist) : null + const m = object(s.music, 'state.music') + const p = m.playlist ? object(m.playlist, 'state.music.playlist') : null const tracks = p?.tracks ?? [] - if (!Array.isArray(tracks)) throw new Error('Некорректный плейлист') - const trackIds = tracks.map((t) => integer(object(t).ID, 1)) - if (new Set(trackIds).size !== trackIds.length) throw new Error('Повтор трека в плейлисте') - const index = integer(m.trackIndex ?? 0) + if (!Array.isArray(tracks)) + throw new ResponseFieldError( + 'state.music.playlist.tracks', + 'массив', + tracks, + 'Некорректный плейлист', + ) + const trackIds = tracks.map((t, i) => + integer( + object(t, `state.music.playlist.tracks[${i}]`).ID, + 1, + Number.MAX_SAFE_INTEGER, + `state.music.playlist.tracks[${i}].ID`, + ), + ) + if (new Set(trackIds).size !== trackIds.length) + throw new ResponseFieldError( + 'state.music.playlist.tracks', + 'уникальные ID', + tracks, + 'Повтор трека в плейлисте', + ) + const index = integer(m.trackIndex ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.music.trackIndex') music = { - version: version(m.stateVersion), - playlistId: p ? integer(p.ID, 1) : null, + version: version(m.stateVersion, 'state.music.stateVersion'), + playlistId: p ? integer(p.ID, 1, Number.MAX_SAFE_INTEGER, 'state.music.playlist.ID') : null, trackId: trackIds[index] ?? null, trackIds, - filenames: tracks.map((t) => text(object(t).filename ?? '')), - position: integer(m.positionMs ?? 0) / 1000, - volume: integer(m.volume ?? 0, 0, 100), + filenames: tracks.map((t, i) => + text(object(t).filename ?? '', `state.music.playlist.tracks[${i}].filename`), + ), + position: + integer(m.positionMs ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.music.positionMs') / 1000, + volume: integer(m.volume ?? 0, 0, 100, 'state.music.volume'), loop: m.loop === true, - playback: enumeration(m.playback, 'MUSIC_PLAYBACK_', [ - 'UNSPECIFIED', - 'STOPPED', - 'PLAYING', - 'PAUSED', - ]), + playback: enumeration( + m.playback, + 'MUSIC_PLAYBACK_', + ['UNSPECIFIED', 'STOPPED', 'PLAYING', 'PAUSED'], + 'state.music.playback', + ), } } return { - gameId: s.gameID == null ? null : integer(s.gameID, 1), - version: version(s.stateVersion), + gameId: s.gameID == null ? null : integer(s.gameID, 1, Number.MAX_SAFE_INTEGER, 'state.gameID'), + version: version(s.stateVersion, 'state.stateVersion'), mode: mode as ScreenMode, - command: enumeration(s.command, 'SCREEN_COMMAND_', [ - 'UNSPECIFIED', - 'PLAY', - 'PAUSE', - 'RESUME', - 'STOP', - 'REPLAY', - 'SEEK', - 'VOLUME', - 'TIMER_START', - 'TIMER_PAUSE', - 'TIMER_RESUME', - 'TIMER_RESET', - ]), - position: integer(s.positionMs ?? 0) / 1000, - volume: integer(s.volume ?? 0, 0, 100), + command: enumeration( + s.command, + 'SCREEN_COMMAND_', + [ + 'UNSPECIFIED', + 'PLAY', + 'PAUSE', + 'RESUME', + 'STOP', + 'REPLAY', + 'SEEK', + 'VOLUME', + 'TIMER_START', + 'TIMER_PAUSE', + 'TIMER_RESUME', + 'TIMER_RESET', + ], + 'state.command', + ), + position: integer(s.positionMs ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.positionMs') / 1000, + volume: integer(s.volume ?? 0, 0, 100, 'state.volume'), sound: s.soundEnabled === true, - text: text(s.text ?? '').slice(0, 10000), + text: text(s.text ?? '', 'state.text').slice(0, 10000), timer: { - label: text(s.timerLabel ?? 'Перерыв').slice(0, 200), + label: text(s.timerLabel ?? 'Перерыв', 'state.timerLabel').slice(0, 200), durationMs: duration * 1000, remainingMs: remaining * 1000, status: (['stopped', 'stopped', 'running', 'paused', 'finished'] as const)[timerState]!, }, resources: { - cover: text(s.coverImage ?? ''), - background: text(s.backgroundImage ?? ''), - intro: text(s.introAudio ?? ''), - conclusion: text(s.conclusionVideo ?? ''), + cover: text(s.coverImage ?? '', 'state.coverImage'), + background: text(s.backgroundImage ?? '', 'state.backgroundImage'), + intro: text(s.introAudio ?? '', 'state.introAudio'), + conclusion: text(s.conclusionVideo ?? '', 'state.conclusionVideo'), }, music, } diff --git a/electron/request-error.test.ts b/electron/request-error.test.ts index 0fc7098..42084e9 100644 --- a/electron/request-error.test.ts +++ b/electron/request-error.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { networkFailure } from './request-error.js' +import { networkFailure, requestErrorContext, ApiError } from './request-error.js' test('безопасная причина соединения извлекается из вложенных Node-ошибок', () => { for (const [code, message] of [ @@ -19,3 +19,17 @@ test('безопасная причина соединения извлекае assert.ok(!result.message.includes('SECRET')) } }) + +test('контекст HTTP сохраняет тип ошибки, статус и исключает query с секретами', () => { + const error = new ApiError(403) + const result = requestErrorContext(error, 'GetGame', 'GET', '/api/games/7?access_token=SECRET') + assert.equal(result, error) + assert.equal(error.status, 403) + assert.match(result.message, /HTTP 403/) + assert.match(result.message, /Описание игры · GET \/api\/games\/7: Нет доступа/) + assert.ok(!result.message.includes('SECRET')) + assert.equal( + requestErrorContext(result, 'GetGame', 'GET', '/api/games/7').message, + result.message, + ) +}) diff --git a/electron/request-error.ts b/electron/request-error.ts index 07e3d22..f0854fc 100644 --- a/electron/request-error.ts +++ b/electron/request-error.ts @@ -75,3 +75,72 @@ export function networkFailure(error: unknown, signal: AbortSignal): NetworkErro : 'Сервер недоступен' return new NetworkError(message, code) } + +function valueType(value: unknown): string { + return value === null + ? 'null' + : value === undefined + ? 'поле отсутствует' + : Array.isArray(value) + ? 'массив' + : typeof value +} +export class ResponseFieldError extends Error { + constructor( + public field: string, + expected: string, + value: unknown, + message = 'Некорректный ответ API', + ) { + super(`${message}: поле ${field} — ожидалось ${expected}, получено ${valueType(value)}`) + } +} +export function fieldError( + field: string | undefined, + expected: string, + value: unknown, + message: string, +): Error { + return field ? new ResponseFieldError(field, expected, value, message) : new Error(message) +} +const contextualErrors = new WeakSet() +export function requestErrorContext( + error: unknown, + operation: string, + method: string, + path: string, +): Error { + const result = error instanceof Error ? error : new Error('Не удалось обработать ответ сервера') + if (contextualErrors.has(result)) return result + const names: Record = { + GetGame: 'Описание игры', + GetGameMusic: 'Музыка игры', + GetScreenPlan: 'План экранов', + SaveScreenPlan: 'Сохранение плана', + SetScreenPlanStep: 'Переход по плану', + GetScreenState: 'Состояние экрана', + AdvanceScreenMusic: 'Следующий трек', + Login: 'Вход', + Refresh: 'Обновление сессии', + GetMe: 'Данные пользователя', + GetGames: 'Список игр', + CreateScreenSession: 'Создание экрана', + BindScreen: 'Подключение экрана', + SetScreenSound: 'Назначение звука', + SetGameMusic: 'Управление музыкой', + SetScreenMode: 'Режим экрана', + SetScreenTimer: 'Таймер', + SendScreenCommand: 'Команда экрана', + GetScreenResults: 'Результаты', + } + const safePath = path.split('?')[0]! + const code = + result instanceof NetworkError + ? ` [${result.code}]` + : result instanceof ApiError && !result.message.includes(`HTTP ${result.status}`) + ? ` [HTTP ${result.status}]` + : '' + result.message = `${names[operation] ?? 'Запрос API'} · ${method} ${safePath}: ${result.message}${code}` + contextualErrors.add(result) + return result +} diff --git a/electron/screen-package.test.ts b/electron/screen-package.test.ts index 65e9cb9..2637aae 100644 --- a/electron/screen-package.test.ts +++ b/electron/screen-package.test.ts @@ -201,7 +201,7 @@ test('отказы API и некорректный JSON не выдают час prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async () => { throw new Error('SECRET') }), - /^Error: Описание игры: Сервер недоступен$/, + /^Error: Описание игры · GET \/api\/games\/7: Сервер недоступен \[NETWORK\]$/, ) }) @@ -261,7 +261,7 @@ test('сетевая ошибка называет запрос и журнал }, ), new RegExp( - `^Error: ${suffix === '/music' ? 'Музыка игры' : suffix === '/screen-plan' ? 'План экранов' : 'Описание игры'}: Не удалось определить адрес сервера$`, + `^Error: ${suffix === '/music' ? 'Музыка игры' : suffix === '/screen-plan' ? 'План экранов' : 'Описание игры'} · GET /api/games/7${suffix}: Не удалось определить адрес сервера \\[ENOTFOUND\\]$`, ), ) assert.deepEqual(failures, [{ path: `/api/games/{id}${suffix}`, reason: 'ENOTFOUND' }]) @@ -298,8 +298,71 @@ test('таймаут и отмена подготовки различаются return Response.json({}) }, ), - new RegExp(`^Error: Описание игры: ${message}$`), + new RegExp(`^Error: Описание игры · GET /api/games/7: ${message} \\[${reason}\\]$`), ) assert.ok(failures.every((f) => f.reason === reason)) } }) + +test('ошибка структуры описания называет запрос, поле и тип, не раскрывая значения', async () => { + for (const [badValue, expectedType] of [ + [null, 'null'], + [undefined, 'поле отсутствует'], + [[], 'массив'], + ['SECRET', 'string'], + ] as const) { + const data = fixture() + ;(data.game.game.scenario as Record).story = badValue + await assert.rejects( + prepareScreenPackage({ server, gameId: 7, token: 'TOKEN-SECRET' }, async (input) => + Response.json( + String(input).endsWith('/music') + ? data.music + : String(input).endsWith('/screen-plan') + ? data.plan + : data.game, + ), + ), + (error) => { + assert.ok(error instanceof Error) + assert.match(error.message, /Описание игры · GET \/api\/games\/7/) + assert.ok(error.message.includes('поле game.scenario.story')) + assert.ok(error.message.includes(`получено ${expectedType}`)) + assert.ok(!error.message.includes('SECRET')) + return true + }, + ) + } +}) + +test('ошибки элементов плана и треков сохраняют индекс и источник ответа', async () => { + const plan = fixture() + plan.plan.plan.steps[0]!.mode = 'SECRET' + const music = fixture() + ;(music.music.playlists[1]!.tracks[0] as Record).filename = { + password: 'SECRET', + } + for (const [data, field, path] of [ + [plan, 'plan.steps[0].mode', '/screen-plan'], + [music, 'music.playlists[1].tracks[0].filename', '/music'], + ] as const) { + await assert.rejects( + prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) => + Response.json( + String(input).endsWith('/music') + ? data.music + : String(input).endsWith('/screen-plan') + ? data.plan + : data.game, + ), + ), + (error) => { + assert.ok(error instanceof Error) + assert.ok(error.message.includes(`/api/games/7${path}:`)) + assert.ok(error.message.includes(`поле ${field}`)) + assert.ok(!error.message.includes('SECRET')) + return true + }, + ) + } +}) diff --git a/electron/screen-package.ts b/electron/screen-package.ts index fc09560..c6b4d38 100644 --- a/electron/screen-package.ts +++ b/electron/screen-package.ts @@ -1,7 +1,7 @@ +import { fieldError, ResponseFieldError, requestErrorContext } from './request-error.js' import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js' import { ApiError, - NetworkError, SessionChangedError, networkFailure, type NetworkErrorCode, @@ -44,34 +44,42 @@ export interface PackageRequest { }) => void } -function object(value: unknown): Record { +function object(value: unknown, field?: string): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) - throw new Error('Некорректный ответ API') + throw fieldError(field, 'объект', value, 'Некорректный ответ API') return value as Record } -function optionalObject(value: unknown) { - return value == null ? {} : object(value) +function optionalObject(value: unknown, field?: string) { + return value == null ? {} : object(value, field) } -function list(value: unknown): unknown[] { +function list(value: unknown, field?: string): unknown[] { if (value == null) return [] - if (!Array.isArray(value)) throw new Error('Некорректный список в ответе API') + if (!Array.isArray(value)) + throw fieldError(field, 'массив', value, 'Некорректный список в ответе API') return value } -function text(value: unknown): string { +function text(value: unknown, field?: string): string { if (value == null) return '' - if (typeof value !== 'string') throw new Error('Некорректный текст в ответе API') + if (typeof value !== 'string') + throw fieldError(field, 'строка', value, 'Некорректный текст в ответе API') return value } -function integer(value: unknown, fallback = 0): number { +function integer(value: unknown, fallback = 0, field?: string): number { if (value == null) return fallback if (typeof value === 'string' && /^\d+$/.test(value)) value = Number(value) if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) - throw new Error('Некорректное число в ответе API') + throw fieldError(field, 'неотрицательное целое число', value, 'Некорректное число в ответе API') return value } -function id(value: unknown): number { - const result = integer(value) - if (result === 0) throw new Error('Отсутствует идентификатор в ответе API') +function id(value: unknown, field?: string): number { + const result = integer(value, 0, field) + if (result === 0) + throw fieldError( + field, + 'положительный идентификатор', + value, + 'Отсутствует идентификатор в ответе API', + ) return result } function hash(value: string) { @@ -107,12 +115,18 @@ const modes = [ 'TIMER', 'TEXT', ] -function mode(value: unknown): number { +function mode(value: unknown, field?: string): number { const result = typeof value === 'string' && value.startsWith('SCREEN_MODE_') ? modes.indexOf(value.slice('SCREEN_MODE_'.length)) - : integer(value) - if (result < 1 || result > 8) throw new Error('Неизвестный режим показа') + : integer(value, 0, field) + if (result < 1 || result > 8) + throw fieldError( + field, + 'режим экрана 1–8 или enum ScreenMode', + value, + 'Неизвестный режим показа', + ) return result } @@ -138,86 +152,140 @@ export function buildScreenPackage( ): ScreenPackage { server = serverURL(server) id(gameId) - const game = object(object(responses.game).game) - if (id(game.ID) !== gameId) throw new Error('API вернул другую игру') - const scenario = object(game.scenario) - const story = object(scenario.story) - const intro = optionalObject(story.introduction) - const conclusion = optionalObject(story.conclusion) + const game = object(object(responses.game, 'gameResponse').game, 'game') + if (id(game.ID, 'game.ID') !== gameId) + throw new ResponseFieldError( + 'game.ID', + 'идентификатор выбранной игры', + game.ID, + 'API вернул другую игру', + ) + const scenario = object(game.scenario, 'game.scenario') + const story = object(scenario.story, 'game.scenario.story') + const intro = optionalObject(story.introduction, 'game.scenario.story.introduction') + const conclusion = optionalObject(story.conclusion, 'game.scenario.story.conclusion') const assets = new Map() - function asset(value: unknown): string | null { - const ref = text(value).trim() + function asset(value: unknown, field: string): string | null { + const ref = text(value, field).trim() if (!ref) return null - const url = resolveMediaURL(server, ref) + let url: string + try { + url = resolveMediaURL(server, ref) + } catch { + throw new ResponseFieldError( + field, + 'адрес HTTP(S) без секретов', + value, + 'Недопустимый адрес файла или ссылка содержит секрет', + ) + } const assetId = hash(url) assets.set(assetId, { id: assetId, url }) return assetId } - const cover = asset(scenario.image) + const cover = asset(scenario.image, 'game.scenario.image') const media = { cover, - background: asset(story.background) ?? cover, - intro: asset(intro.audio), - conclusion: asset(conclusion.video), + background: asset(story.background, 'game.scenario.story.background') ?? cover, + intro: asset(intro.audio, 'game.scenario.story.introduction.audio'), + conclusion: asset(conclusion.video, 'game.scenario.story.conclusion.video'), } - const plan = optionalObject(object(responses.plan).plan) - const steps = list(plan.steps).map((value) => { - const step = object(value) + const plan = optionalObject(object(responses.plan, 'planResponse').plan, 'plan') + const steps = list(plan.steps, 'plan.steps').map((value, index) => { + const field = `plan.steps[${index}]` + const step = object(value, field) const result = { - mode: mode(step.mode), - text: text(step.text), - timerLabel: text(step.timerLabel), - timerDurationSeconds: integer(step.timerDurationSeconds), + mode: mode(step.mode, `${field}.mode`), + text: text(step.text, `${field}.text`), + timerLabel: text(step.timerLabel, `${field}.timerLabel`), + timerDurationSeconds: integer(step.timerDurationSeconds, 0, `${field}.timerDurationSeconds`), } - if (result.mode === 8 && !result.text.trim()) throw new Error('Пустой текст шага показа') + if (result.mode === 8 && !result.text.trim()) + throw new ResponseFieldError( + `${field}.text`, + 'непустая строка', + step.text, + 'Пустой текст шага показа', + ) if ( result.mode === 7 && (result.timerDurationSeconds < 1 || result.timerDurationSeconds > 86400) ) - throw new Error('Некорректная длительность таймера') + throw new ResponseFieldError( + `${field}.timerDurationSeconds`, + 'целое число от 1 до 86400', + step.timerDurationSeconds, + 'Некорректная длительность таймера', + ) return result }) - const musicResponse = object(responses.music) - const state = optionalObject(musicResponse.state) - const selected = state.playlist == null ? null : object(state.playlist) + const musicResponse = object(responses.music, 'musicResponse') + const state = optionalObject(musicResponse.state, 'music.state') + const selected = state.playlist == null ? null : object(state.playlist, 'music.state.playlist') const music = { scenarioPlaylistId: - integer(musicResponse.scenarioPlaylistId ?? scenario.music_playlist_id) || null, - gamePlaylistId: integer(musicResponse.gamePlaylistId) || null, - selectedPlaylistId: selected ? id(selected.ID) : null, + integer( + musicResponse.scenarioPlaylistId ?? scenario.music_playlist_id, + 0, + musicResponse.scenarioPlaylistId == null + ? 'game.scenario.music_playlist_id' + : 'music.scenarioPlaylistId', + ) || null, + gamePlaylistId: integer(musicResponse.gamePlaylistId, 0, 'music.gamePlaylistId') || null, + selectedPlaylistId: selected ? id(selected.ID, 'music.state.playlist.ID') : null, } - const available = new Map>() - for (const value of list(musicResponse.playlists)) { - const playlist = object(value) - available.set(id(playlist.ID), playlist) + const available = new Map; field: string }>() + for (const [index, value] of list(musicResponse.playlists, 'music.playlists').entries()) { + const field = `music.playlists[${index}]` + const playlist = object(value, field) + available.set(id(playlist.ID, `${field}.ID`), { value: playlist, field }) } // Текущий плейлист берём из того же ответа, даже если его нет в библиотеке. - if (selected) available.set(id(selected.ID), selected) + if (selected) + available.set(id(selected.ID, 'music.state.playlist.ID'), { + value: selected, + field: 'music.state.playlist', + }) const selectedIds = [ ...new Set(Object.values(music).filter((value): value is number => value !== null)), ].sort((a, b) => a - b) const playlists = selectedIds.map((playlistId) => { - const playlist = available.get(playlistId) - if (!playlist) throw new Error('Не найден связанный плейлист') - const tracks = list(playlist.tracks) - .map((value) => { - const track = object(value) - const assetId = asset(track.filename) - if (!assetId) throw new Error('Не указан файл трека') + const entry = available.get(playlistId) + if (!entry) + throw new ResponseFieldError( + 'music.playlists', + 'связанный плейлист', + undefined, + 'Не найден связанный плейлист', + ) + const playlist = entry.value + const field = entry.field + const tracks = list(playlist.tracks, `${field}.tracks`) + .map((value, index) => { + const trackField = `${field}.tracks[${index}]` + const track = object(value, trackField) + const assetId = asset(track.filename, `${trackField}.filename`) + if (!assetId) + throw new ResponseFieldError( + `${trackField}.filename`, + 'непустая строка', + track.filename, + 'Не указан файл трека', + ) return { - id: id(track.ID), - name: text(track.name), - position: integer(track.position), + id: id(track.ID, `${trackField}.ID`), + name: text(track.name, `${trackField}.name`), + position: integer(track.position, 0, `${trackField}.position`), assetId, } }) .sort((a, b) => a.position - b.position || a.id - b.id) - return { id: playlistId, name: text(playlist.name), tracks } + return { id: playlistId, name: text(playlist.name, `${field}.name`), tracks } }) const data = { formatVersion: 1 as const, server, - game: { id: gameId, name: text(game.name) }, + game: { id: gameId, name: text(game.name, 'game.name') }, media, steps, music, @@ -270,36 +338,34 @@ export async function prepareScreenPackage( : 'HTTP' : 'SESSION_CHANGED', }) - throw error + throw requestErrorContext(error, meta.method, requestData.method, `/${requestData.path}`) } 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) + throw requestErrorContext(failure, meta.method, requestData.method, `/${requestData.path}`) } if (!response.ok) { request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP', }) - throw new Error( - response.status === 401 - ? 'Требуется авторизация' - : response.status === 403 - ? 'Нет доступа к игре' - : `Ошибка API: HTTP ${response.status}`, + throw requestErrorContext( + new ApiError(response.status), + meta.method, + requestData.method, + `/${requestData.path}`, ) } try { return await response.json() } catch { request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' }) - throw new Error('Некорректный JSON в ответе API') + throw requestErrorContext( + new Error('Некорректный JSON в ответе API'), + meta.method, + requestData.method, + `/${requestData.path}`, + ) } }) const [game, plan, music] = await Promise.all([ @@ -307,7 +373,22 @@ export async function prepareScreenPackage( client.GetScreenPlan({ gameID: request.gameId }), client.GetGameMusic({ gameID: request.gameId }), ]) - return buildScreenPackage(server, request.gameId, { game, plan, music }) + try { + return buildScreenPackage(server, request.gameId, { game, plan, music }) + } catch (error) { + const field = error instanceof ResponseFieldError ? error.field : 'game' + const suffix = field.startsWith('plan') + ? '/screen-plan' + : field.startsWith('music') + ? '/music' + : '' + throw requestErrorContext( + error, + suffix === '/music' ? 'GetGameMusic' : suffix ? 'GetScreenPlan' : 'GetGame', + 'GET', + `/api/games/${request.gameId}${suffix}`, + ) + } } // Восстанавливаем только разрешённые поля и проверяем отпечаток перед работой с диском. diff --git a/electron/screen-plan.ts b/electron/screen-plan.ts index d29efd4..84b1bdd 100644 --- a/electron/screen-plan.ts +++ b/electron/screen-plan.ts @@ -1,3 +1,4 @@ +import { ResponseFieldError } from './request-error.js' import type { ScreenMode, ScreenPlan } from '../src/types/screen.js' import { integer, object, text } from './remote-state.js' @@ -14,25 +15,53 @@ const modes = [ ] export function parsePlan(value: unknown): ScreenPlan { - const plan = value == null ? {} : object(value) + const plan = value == null ? {} : object(value, 'plan') const list = plan.steps ?? [] - if (!Array.isArray(list)) throw new Error('Некорректные шаги плана') - const steps = list.map((value) => { - const step = object(value) - const mode = integer( - typeof step.mode === 'string' && step.mode.startsWith('SCREEN_MODE_') - ? modes.indexOf(step.mode.slice(12)) - : step.mode, - 1, - 8, - ) as ScreenMode - const content = text(step.text ?? '').trim() - const label = text(step.timerLabel ?? '').trim() - const seconds = integer(step.timerDurationSeconds ?? 0, 0, 86400) + if (!Array.isArray(list)) + throw new ResponseFieldError('plan.steps', 'массив', list, 'Некорректные шаги плана') + const steps = list.map((value, index) => { + const field = `plan.steps[${index}]` + const step = object(value, field) + let mode: ScreenMode + try { + mode = integer( + typeof step.mode === 'string' && step.mode.startsWith('SCREEN_MODE_') + ? modes.indexOf(step.mode.slice(12)) + : step.mode, + 1, + 8, + `${field}.mode`, + ) as ScreenMode + } catch { + throw new ResponseFieldError( + `${field}.mode`, + 'режим экрана 1–8 или enum ScreenMode', + step.mode, + 'Некорректный режим экрана', + ) + } + const content = text(step.text ?? '', `${field}.text`).trim() + const label = text(step.timerLabel ?? '', `${field}.timerLabel`).trim() + const seconds = integer( + step.timerDurationSeconds ?? 0, + 0, + 86400, + `${field}.timerDurationSeconds`, + ) if (mode === 8 && (!content || Buffer.byteLength(content) > 10000)) - throw new Error('Текст шага должен содержать от 1 до 10000 байт') + throw new ResponseFieldError( + `${field}.text`, + 'строка от 1 до 10000 байт', + step.text, + 'Текст шага должен содержать от 1 до 10000 байт', + ) if (mode === 7 && (!seconds || Buffer.byteLength(label) > 200)) - throw new Error('Укажите таймер от 1 до 86400 секунд и подпись до 200 байт') + throw new ResponseFieldError( + !seconds ? `${field}.timerDurationSeconds` : `${field}.timerLabel`, + !seconds ? 'число от 1 до 86400' : 'строка до 200 байт', + !seconds ? step.timerDurationSeconds : step.timerLabel, + 'Укажите таймер от 1 до 86400 секунд и подпись до 200 байт', + ) return { mode, text: content, @@ -40,5 +69,13 @@ export function parsePlan(value: unknown): ScreenPlan { timerDurationSeconds: seconds, } }) - return { steps, currentStep: integer(plan.currentStep ?? 0, 0, Math.max(0, steps.length - 1)) } + return { + steps, + currentStep: integer( + plan.currentStep ?? 0, + 0, + Math.max(0, steps.length - 1), + 'plan.currentStep', + ), + } } diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index cde5fe6..fc6e015 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -14,6 +14,7 @@ module.exports = async function onlineSmoke({ }) { let gamesCalls = 0 let varyGames = false + let invalidStory = false let version = 1 let offline = false let polls = 0 @@ -63,7 +64,7 @@ module.exports = async function onlineSmoke({ game: { ID: 6, name: 'HTTP smoke', - scenario: { story: { introduction: { audio: 'intro.wav' } } }, + scenario: { story: invalidStory ? null : { introduction: { audio: 'intro.wav' } } }, }, }) if (path === '/api/games/6/screen-plan') { @@ -250,6 +251,28 @@ module.exports = async function onlineSmoke({ 'control target stays on local package despite server game selection', ) assert.notEqual(selectedBeforeDownload, 'HTTP smoke') + invalidStory = true + await assert.rejects(act({ action: 'connect', gameId: 6 }), /game.scenario.story/) + await poll( + () => + evaluate( + `document.querySelector('[data-testid="connection-error"]')?.textContent.includes('game.scenario.story')`, + ), + 'detailed API error shown', + ) + const errorText = await evaluate( + `document.querySelector('[data-testid="connection-error"]').textContent`, + ) + assert.ok(errorText.includes('Описание игры · GET /api/games/6')) + assert.ok(errorText.includes('получено null')) + assert.ok(!errorText.includes('TEST-ACCESS')) + assert.equal( + await evaluate( + `getComputedStyle(document.querySelector('[data-testid="connection-error"]')).overflowWrap`, + ), + 'anywhere', + ) + invalidStory = false await act({ action: 'connect', gameId: 6 }) const packages = await evaluate('window.screenBridge.packages()') const pkg = packages.find((p) => p.name === 'HTTP smoke') diff --git a/src/components/ConnectionPanel.vue b/src/components/ConnectionPanel.vue index cfe6c10..fc6fe3f 100644 --- a/src/components/ConnectionPanel.vue +++ b/src/components/ConnectionPanel.vue @@ -228,7 +228,7 @@ onUnmounted(() => clearInterval(timer)) {{ state.preparation.total }} файлов; текущая загрузка {{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ.

- + {{ state.preparation.error }}. Прежний показ сохраняется до готовности замены. clearInterval(timer)) >Повторить подготовку - {{ state.error }} + {{ state.error }} {{ state.notice }} - {{ notice }} + {{ notice }}

Для редактирования войдите на сервер выбранной подготовленной игры.