Files
evening_detective_screen/scripts/online-smoke.cjs
T
2026-10-18 01:38:02 +07:00

372 lines
15 KiB
JavaScript

const assert = require('node:assert/strict')
const { createServer } = require('node:http')
const { readFileSync } = require('node:fs')
const { join } = require('node:path')
module.exports = async function onlineSmoke({
evaluate,
inspect,
state,
command,
poll,
displayId,
root,
}) {
let version = 1
let offline = false
let polls = 0
let currentCommand = 'SCREEN_COMMAND_PLAY'
let plan = { steps: [{ mode: 2 }], currentStep: 0 }
let planSaveFailure = false
let remoteMode = 2
let remoteText = ''
let timerLabel = 'Перерыв'
let timerDurationSeconds = 600
let timerState = 1
const requests = []
const server = createServer(async (req, res) => {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}
const path = req.url
requests.push(path)
const send = (value, status = 200) => {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(value))
}
if (path === '/api/auth/login') {
assert.equal(body.email, 'organizer@example.test')
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/6')
return send({
game: {
ID: 6,
name: 'HTTP smoke',
scenario: { story: { introduction: { audio: 'intro.wav' } } },
},
})
if (path === '/api/games/6/screen-plan') {
if (req.method === 'PUT') {
assert.equal(req.headers.authorization, 'Bearer TEST-ACCESS')
if (planSaveFailure) return send({}, 403)
plan = body
}
return send({ plan: plan.steps.length ? plan : null })
}
if (path === '/api/games/6/screen-plan/step') {
assert.equal(req.headers.authorization, 'Bearer TEST-ACCESS')
const step = plan.steps[body.currentStep]
if (!step) return send({}, 400)
plan.currentStep = body.currentStep
remoteMode = step.mode
remoteText = step.text || ''
timerLabel = step.timerLabel || 'Перерыв'
timerDurationSeconds = step.timerDurationSeconds || 600
timerState = 1
currentCommand = 0
version++
return send({ plan })
}
if (path === '/api/games/6/screens/command') {
currentCommand = body.command
if (body.command === 8 || body.command === 10) timerState = 2
if (body.command === 9) timerState = 3
if (body.command === 11) timerState = 1
version++
return send({})
}
if (path === '/api/games/6/music') return send({})
if (path === '/api/files/intro.wav') {
assert.equal(req.headers.authorization, undefined)
res.writeHead(200, { 'Content-Type': 'audio/wav' })
return res.end(readFileSync(join(root, 'fixtures/audio.wav')))
}
if (path === '/api/screens') return send({ screenID: '67', accessToken: 'TEST-SCREEN' })
if (path === '/api/screens/67/bind') return send({})
if (path === '/api/screens/state') {
polls++
assert.equal(req.headers.authorization, undefined)
assert.equal(body.accessToken, 'TEST-SCREEN')
if (offline) return send({}, 503)
return send({
state: {
gameID: 6,
mode: remoteMode,
text: remoteText,
timerLabel,
timerDurationSeconds,
timerRemainingSeconds: timerDurationSeconds,
timerState,
soundEnabled: true,
volume: 80,
stateVersion: String(version),
command: currentCommand,
},
})
}
if (path === '/api/screens/results')
return send({
questions: [{ code: 'A', text: 'Вопрос' }],
teams: [
{
team: { ID: 't', name: 'Детективы', password: 'PRIVATE' },
place: 1,
totalScore: 3,
actionsCount: 1,
answers: [{ questionCode: 'A', score: 3 }],
},
],
})
send({}, 404)
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const url = `http://127.0.0.1:${server.address().port}`
const act = (intent) => evaluate(`window.screenBridge.onlineAction(${JSON.stringify(intent)})`)
try {
await evaluate('window.screenBridge.closeOutput()')
// Вход через настоящую форму пульта, загрузка через защищённый IPC.
await poll(() => evaluate('!!document.querySelector("#server")'), 'login form')
await evaluate(`(() => {
for (const [id, value] of Object.entries(${JSON.stringify({ server: url, email: 'organizer@example.test', password: 'test-password' })})) {
const element = document.querySelector('#' + id)
element.value = value; element.dispatchEvent(new Event('input', { bubbles: true }))
}
})()`)
await evaluate(
"Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Войти').click()",
)
await poll(
async () => (await evaluate('window.screenBridge.onlineState()')).authenticated,
'HTTP login',
)
await poll(
async () => !(await evaluate('window.screenBridge.onlineState()')).busy,
'login complete',
)
await poll(
() =>
evaluate(
`Array.from(document.querySelectorAll('.n-base-selection')).some(e => e.textContent.includes('Выберите игру для подготовки'))`,
),
'server game selector',
)
await evaluate(
`Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру для подготовки')).click()`,
)
await poll(
() =>
evaluate(
`Array.from(document.querySelectorAll('.n-base-select-option')).some(e => e.textContent.includes('HTTP smoke · Завершена'))`,
),
'translated game status',
)
await evaluate(
`Array.from(document.querySelectorAll('.n-base-select-option')).find(e => e.textContent.includes('HTTP smoke · Завершена')).click()`,
)
const selectedBeforeDownload = (await state()).catalog.name
await poll(
() =>
evaluate(
`document.querySelector('[data-testid="control-game"]')?.textContent.includes(${JSON.stringify(selectedBeforeDownload)})`,
),
'control target stays on local package despite server game selection',
)
assert.notEqual(selectedBeforeDownload, 'HTTP smoke')
await act({ action: 'download', gameId: 6 })
const packages = await evaluate('window.screenBridge.packages()')
const pkg = packages.find((p) => p.name === 'HTTP smoke')
assert.ok(pkg)
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(pkg.key)})`)
await poll(
() =>
evaluate(
`document.querySelector('[data-testid="control-game"]')?.textContent.includes('HTTP smoke')`,
),
'control target follows selected local package',
)
await act({ action: 'connect' })
await evaluate(`window.screenBridge.show(${displayId})`)
await poll(
async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected',
'HTTP baseline',
)
await poll(
() =>
evaluate(
`document.querySelector('[data-testid="sound-assignment"]')?.textContent.includes('Экран назначен для звука')`,
),
'server sound assignment shown in UI',
)
assert.equal((await state()).channels.main.desired, 'stopped')
version++
await poll(
async () => (await state()).channels.main.status === 'playing',
'server play -> local audio',
)
assert.match(
await inspect('document.querySelector("[data-channel=main-audio]").src'),
/^screen-app:/,
)
await assert.rejects(act({ action: 'download', gameId: 6 }), /Закройте показ/)
await command({ action: 'pause', channel: 'main' })
const pausePolls = polls
await poll(() => polls > pausePolls + 1, 'duplicate state polled')
assert.equal((await state()).channels.main.desired, 'paused')
offline = true
await poll(
async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'offline',
'offline indication',
)
await command({ action: 'play', channel: 'main' })
offline = false
currentCommand = 'SCREEN_COMMAND_STOP'
version++
await poll(
async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected',
'reconnect baseline',
)
assert.equal((await state()).channels.main.desired, 'playing')
version++
await poll(async () => (await state()).channels.main.status === 'stopped', 'new remote stop')
const { powerMonitor } = require('electron')
powerMonitor.emit('suspend')
assert.equal((await state()).outputOpen, false)
powerMonitor.emit('resume')
currentCommand = 'SCREEN_COMMAND_PLAY'
version++
const beforeReopenPolls = polls
await evaluate(`window.screenBridge.show(${displayId})`)
await poll(() => polls > beforeReopenPolls, 'post-incident baseline')
assert.equal((await state()).channels.main.desired, 'stopped')
version++
await poll(
async () => (await state()).channels.main.status === 'playing',
'new command after incident',
)
await act({ action: 'results' })
assert.equal((await state()).results.teams[0].name, 'Детективы')
assert.equal(JSON.stringify(await state()).includes('PRIVATE'), false)
for (const method of [
'planStatus()',
'onlineState()',
"onlineAction({action:'logout'})",
'closeOutput()',
]) {
assert.equal(
await inspect(`window.screenBridge.${method}.then(() => false, () => true)`),
true,
)
}
// Настоящий редактор: черновик, ошибка сохранения, порядок и переходы.
const clickPlan = async (label) => {
await poll(
() =>
evaluate(`(() => {
const button = Array.from(document.querySelectorAll('[data-testid="screen-plan"] button'))
.find(b => b.textContent.trim() === ${JSON.stringify(label)})
if (!button || button.disabled) return false
button.click(); return true
})()`),
`plan button ${label}`,
)
}
const setInput = (selector, value) =>
evaluate(`(() => {
const input = document.querySelector(${JSON.stringify(selector)})
input.focus()
input.value = ${JSON.stringify(value)}
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
input.blur()
})()`)
await clickPlan('Редактировать')
await clickPlan('Добавить шаг')
await setInput('[data-testid="screen-plan"] textarea', 'Текст из редактора')
await clickPlan('Добавить шаг')
await evaluate(
`Array.from(document.querySelectorAll('[data-testid="screen-plan"] .n-base-selection')).at(-1).click()`,
)
await poll(
() =>
evaluate(
`Array.from(document.querySelectorAll('.n-base-select-option')).some(e => e.textContent.trim() === 'Таймер')`,
),
'timer option',
)
await evaluate(
`Array.from(document.querySelectorAll('.n-base-select-option')).find(e => e.textContent.trim() === 'Таймер').click()`,
)
await setInput('#plan-label-2', 'Отдых')
await setInput('#plan-seconds-2', '60')
assert.equal((await state()).catalog.steps.length, 1, 'draft is not published')
const beforeSaveScene = (await state()).scene.revision
planSaveFailure = true
await clickPlan('Сохранить план')
await poll(
() => evaluate(`document.body.textContent.includes('Нет доступа к действию')`),
'save failure shown',
)
assert.equal((await state()).catalog.steps.length, 1)
assert.ok(await evaluate(`!!document.querySelector('[data-testid="screen-plan"] textarea')`))
planSaveFailure = false
await clickPlan('Сохранить план')
await poll(async () => (await state()).catalog.steps.length === 3, 'plan saved')
assert.equal((await state()).scene.revision, beforeSaveScene)
assert.equal(plan.steps[1].text, 'Текст из редактора')
assert.equal(plan.steps[2].timerDurationSeconds, 60)
await evaluate(`document.querySelector('[data-step-index="1"] button').click()`)
await poll(
async () =>
(await state()).scene.text === 'Текст из редактора' &&
(await state()).scene.stepIndex === 1,
'show text step',
)
await evaluate(`document.querySelector('[data-step-index="2"] button').click()`)
await poll(async () => (await state()).scene.stepIndex === 2, 'show timer step')
assert.equal((await state()).timer.status, 'stopped')
assert.equal((await state()).channels.main.desired, 'stopped')
await clickPlan('Старт')
await poll(async () => (await state()).timer.status === 'running', 'plan timer start')
await clickPlan('Пауза')
await poll(async () => (await state()).timer.status === 'paused', 'plan timer pause')
await clickPlan('Редактировать')
await evaluate(`document.querySelector('[aria-label="Поднять шаг 3"]').click()`)
await clickPlan('Сохранить план')
await poll(async () => (await state()).catalog.steps[1].mode === 7, 'reorder plan saved')
assert.equal((await state()).scene.stepIndex, null, 'edited index is not highlighted')
await clickPlan('Редактировать')
await setInput('[data-testid="screen-plan"] textarea', 'Несохранённый черновик')
await clickPlan('Отменить')
await poll(
() =>
evaluate(
`Array.from(document.querySelectorAll('button')).some(b => b.textContent.trim() === 'Отменить изменения')`,
),
'discard dialog',
)
await evaluate(
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Отменить изменения').click()`,
)
await poll(
() => evaluate(`!document.querySelector('[data-testid="screen-plan"] textarea')`),
'discarded draft',
)
assert.equal((await state()).catalog.steps[2].text, 'Текст из редактора')
await clickPlan('Редактировать')
await clickPlan('Удалить план')
await clickPlan('Сохранить план')
await poll(async () => (await state()).catalog.steps.length === 0, 'plan deleted')
console.log('SCREEN_PLAN_SMOKE_PASS')
await act({ action: 'logout' })
assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false)
console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length }))
} finally {
server.closeAllConnections()
await new Promise((resolve) => server.close(resolve))
}
}