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 }}
@@ -262,4 +268,9 @@ onUnmounted(() => clearInterval(timer))
.connection-content > button {
align-self: flex-start;
}
+
+.connection-error {
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
diff --git a/src/components/ScreenPlanPanel.vue b/src/components/ScreenPlanPanel.vue
index d24769a..b6158db 100644
--- a/src/components/ScreenPlanPanel.vue
+++ b/src/components/ScreenPlanPanel.vue
@@ -48,7 +48,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
- {{ notice }}
+ {{ notice }}
Для редактирования войдите на сервер выбранной подготовленной игры.
@@ -199,6 +199,10 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({