This commit is contained in:
2026-10-18 04:32:58 +07:00
parent e85d899fd7
commit c36d3fd314
14 changed files with 446 additions and 50 deletions
+54
View File
@@ -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 не выполнялись.
+11
View File
@@ -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` содержит только шаблон пути и разрешённый код причины;
пароли, токены, содержимое ответа и полные адреса туда не записываются.
+9 -1
View File
@@ -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'])
})
+7
View File
@@ -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<Record<Event, Record<string, readonly string[]>>> = {
'output-close': {
reason: [
@@ -25,6 +27,10 @@ const fields: Partial<Record<Event, Record<string, readonly string[]>>> = {
'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
+15 -8
View File
@@ -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 = () => {
+49 -1
View File
@@ -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<string, unknown> | null,
mediaHandler: null as ((url: string) => Promise<Response>) | 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)
})
+22 -27
View File
@@ -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<Response> {
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<Record<string, unknown>> {
@@ -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))
+21
View File
@@ -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'))
}
})
+77
View File
@@ -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)
}
+68 -1
View File
@@ -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))
}
})
+41 -5
View File
@@ -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<string, unknown> {
@@ -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')
}
}
+53 -2
View File
@@ -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()`,
)
+17 -5
View File
@@ -1,14 +1,14 @@
<script setup lang="ts">
import { NAlert, NButton, NCard, NInput, NSelect, NSpace, useMessage } from 'naive-ui'
import { onMounted, onUnmounted, ref } from 'vue'
import type { OnlineIntent, OnlineState } from '../types/online'
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../types/online'
defineProps<{ outputOpen: boolean; selectedKey: string | null; selectedName: string | null }>()
const bridge = window.screenBridge
const message = useMessage()
const isReady = ref(false)
const state = ref<OnlineState>({
server: '',
server: DEFAULT_SERVER,
authenticated: false,
remembered: false,
busy: false,
@@ -23,7 +23,7 @@ const state = ref<OnlineState>({
preparation: { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '' },
packagesRevision: 0,
})
const server = ref('')
const server = ref(DEFAULT_SERVER)
const email = ref('')
const password = ref('')
const gameId = ref<number | null>(null)
@@ -90,7 +90,7 @@ onUnmounted(() => clearInterval(timer))
<template>
<n-card v-if="bridge && isReady" title="Игра и сервер">
<n-space vertical>
<div class="connection-content">
<template v-if="!state.authenticated">
<label for="server">Адрес сервера</label>
<n-input
@@ -248,6 +248,18 @@ onUnmounted(() => clearInterval(timer))
</p>
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
</template>
</n-space>
</div>
</n-card>
</template>
<style scoped>
.connection-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.connection-content > button {
align-self: flex-start;
}
</style>
+2
View File
@@ -1,5 +1,7 @@
import type { ScreenStep } from './screen.js'
export const DEFAULT_SERVER = 'https://evening-detective.crabs-games.art'
export type OnlineIntent =
| { action: 'plan-save'; key: string; steps: ScreenStep[] }
| { action: 'plan-refresh' }