841 lines
32 KiB
JavaScript
841 lines
32 KiB
JavaScript
const assert = require('node:assert/strict')
|
|
const { createServer } = require('node:http')
|
|
const { readFileSync, writeFileSync, mkdtempSync, rmSync } = require('node:fs')
|
|
const { tmpdir } = require('node:os')
|
|
const { dialog } = require('electron')
|
|
const { join } = require('node:path')
|
|
|
|
module.exports = async function onlineSmoke({
|
|
evaluate,
|
|
inspect,
|
|
state,
|
|
command,
|
|
poll,
|
|
displayId,
|
|
root,
|
|
}) {
|
|
let gamesCalls = 0
|
|
let varyGames = false
|
|
let invalidStory = false
|
|
let introAudio = 'intro.wav'
|
|
let version = 1
|
|
let offline = false
|
|
let polls = 0
|
|
let music = null
|
|
let holdTrack = false
|
|
let releaseTrack
|
|
let trackRequested = false
|
|
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 library = [
|
|
{
|
|
ID: 9,
|
|
name: 'Редактор музыки',
|
|
tracks: [
|
|
{ ID: 91, name: 'Раз', filename: 'music-a.wav', position: 0 },
|
|
{ ID: 92, name: 'Два', filename: 'music-b.wav', position: 1 },
|
|
],
|
|
},
|
|
]
|
|
let nextPlaylistId = 10
|
|
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
|
|
if (offline && path !== '/api/screens/state') {
|
|
res.destroy()
|
|
return
|
|
}
|
|
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') {
|
|
gamesCalls++
|
|
return send({
|
|
games:
|
|
varyGames && gamesCalls % 2 === 0
|
|
? []
|
|
: [{ ID: 6, name: 'HTTP smoke', status: 'finish' }],
|
|
})
|
|
}
|
|
if (path === '/api/games/6')
|
|
return send({
|
|
game: {
|
|
ID: 6,
|
|
name: 'HTTP smoke',
|
|
scenario: { story: invalidStory ? [] : null },
|
|
},
|
|
})
|
|
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 === 'SCREEN_COMMAND_TIMER_START' ||
|
|
body.command === 'SCREEN_COMMAND_TIMER_RESUME'
|
|
)
|
|
timerState = 2
|
|
if (body.command === 'SCREEN_COMMAND_TIMER_PAUSE') timerState = 3
|
|
if (body.command === 'SCREEN_COMMAND_TIMER_RESET') timerState = 1
|
|
version++
|
|
return send({})
|
|
}
|
|
if (path === '/api/playlists') {
|
|
if (req.method === 'POST') library.push({ ID: nextPlaylistId++, name: body.name, tracks: [] })
|
|
return send({ playlists: library })
|
|
}
|
|
const playlistMatch = path.match(/^\/api\/playlists\/(\d+)(?:\/tracks(?:\/(\d+|order))?)?$/)
|
|
if (playlistMatch) {
|
|
const item = library.find((p) => p.ID === Number(playlistMatch[1]))
|
|
if (!item) return send({}, 404)
|
|
if (path.endsWith('/order')) {
|
|
item.tracks = body.trackIDs.map((id, position) => ({
|
|
...item.tracks.find((t) => t.ID === id),
|
|
position,
|
|
}))
|
|
if (music?.playlist.ID === item.ID) {
|
|
const trackID = music.playlist.tracks[music.trackIndex].ID
|
|
music = {
|
|
...music,
|
|
stateVersion: String(Number(music.stateVersion) + 1),
|
|
playlist: structuredClone(item),
|
|
trackIndex: item.tracks.findIndex((t) => t.ID === trackID),
|
|
}
|
|
}
|
|
} else if (path.endsWith('/tracks') && req.method === 'POST') {
|
|
assert.equal(body.filename, 'native.mp3')
|
|
assert.equal(Buffer.from(body.data, 'base64').toString(), 'ID3test')
|
|
item.tracks.push({
|
|
ID: 93,
|
|
name: body.name,
|
|
filename: 'native.mp3',
|
|
position: item.tracks.length,
|
|
})
|
|
} else if (playlistMatch[2] && req.method === 'DELETE')
|
|
item.tracks = item.tracks.filter((t) => t.ID !== Number(playlistMatch[2]))
|
|
else if (req.method === 'PUT') item.name = body.name
|
|
else if (req.method === 'DELETE') library.splice(library.indexOf(item), 1)
|
|
return send({ playlist: item })
|
|
}
|
|
if (path === '/api/games/6/music') {
|
|
if (req.method === 'POST') {
|
|
const item = library.find((p) => p.ID === body.playlistID)
|
|
music = {
|
|
...body,
|
|
stateVersion: String(Number(music?.stateVersion || 0) + 1),
|
|
playlist: structuredClone(item),
|
|
}
|
|
}
|
|
return send(
|
|
music
|
|
? { state: music, gamePlaylistId: music.playlist.ID, playlists: [music.playlist] }
|
|
: {},
|
|
)
|
|
}
|
|
if (
|
|
['/api/files/intro.wav', '/api/files/music-a.wav', '/api/files/music-b.wav'].includes(path)
|
|
) {
|
|
if (path.endsWith('music-b.wav') && holdTrack) {
|
|
trackRequested = true
|
|
await new Promise((resolve) => {
|
|
releaseTrack = resolve
|
|
})
|
|
}
|
|
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') {
|
|
assert.equal(body.initializeAudio, true)
|
|
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,
|
|
music,
|
|
introAudio,
|
|
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 poll(
|
|
() =>
|
|
evaluate(
|
|
`document.querySelectorAll('main.control > .n-card').length === 4 && document.querySelectorAll('.n-card-header__main').length === 5`,
|
|
),
|
|
'five control cards',
|
|
)
|
|
assert.deepEqual(
|
|
await evaluate(
|
|
`Array.from(document.querySelectorAll('.n-card-header__main')).map(e => e.textContent.trim())`,
|
|
),
|
|
['Сервер', 'Игра', 'Экран', 'План экранов', 'План музыки'],
|
|
)
|
|
assert.equal(
|
|
await evaluate(`document.querySelectorAll('details').length`),
|
|
0,
|
|
'no developer metrics',
|
|
)
|
|
assert.equal(
|
|
await evaluate(
|
|
`document.body.textContent.includes('Автоматический сон') || document.body.textContent.includes('HDMI')`,
|
|
),
|
|
false,
|
|
'no permanent technical instructions',
|
|
)
|
|
for (const label of [
|
|
'Проверить изменения',
|
|
'Обновить снимок результатов',
|
|
'Подключить выбранную игру',
|
|
'Подключить управление этой игрой',
|
|
'Повторить с начала',
|
|
])
|
|
assert.equal(
|
|
await evaluate(
|
|
`Array.from(document.querySelectorAll('button')).some(b => b.textContent.trim() === ${JSON.stringify(label)})`,
|
|
),
|
|
false,
|
|
'removed button: ' + label,
|
|
)
|
|
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 poll(
|
|
() =>
|
|
evaluate(`(() => {
|
|
const select = Array.from(document.querySelectorAll('.n-base-selection')).find(e => e.textContent.includes('Выберите игру'))
|
|
return !!select && !select.classList.contains('n-base-selection--disabled') && !document.body.textContent.includes('Список игр пока пуст')
|
|
})()`),
|
|
'updated game list rendered',
|
|
)
|
|
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')
|
|
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('получено массив'))
|
|
assert.ok(!errorText.includes('TEST-ACCESS'))
|
|
assert.equal(
|
|
await evaluate(
|
|
`getComputedStyle(document.querySelector('[data-testid="connection-error"]')).overflowWrap`,
|
|
),
|
|
'anywhere',
|
|
)
|
|
invalidStory = false
|
|
await poll(
|
|
() =>
|
|
evaluate(`(() => {
|
|
const button = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Подключить')
|
|
if (!button || button.disabled) return false
|
|
button.click(); return true
|
|
})()`),
|
|
'single connect button',
|
|
)
|
|
await poll(
|
|
async () => !!(await evaluate('window.screenBridge.onlineState()')).screenId,
|
|
'selected game connected',
|
|
)
|
|
const packages = await evaluate('window.screenBridge.packages()')
|
|
const pkg = packages.find((p) => p.name === 'HTTP smoke')
|
|
assert.ok(pkg)
|
|
await poll(
|
|
() =>
|
|
evaluate(
|
|
`document.querySelector('[data-testid="control-game"]')?.textContent.includes('HTTP smoke')`,
|
|
),
|
|
'control target follows selected local package',
|
|
)
|
|
await poll(
|
|
async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected',
|
|
'connected with output closed',
|
|
)
|
|
assert.equal((await state()).outputOpen, false)
|
|
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',
|
|
)
|
|
await poll(
|
|
async () => (await state()).channels.main.desired === 'playing',
|
|
'initial server state applied',
|
|
)
|
|
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',
|
|
)
|
|
await poll(
|
|
async () => (await state()).channels.main.status === 'stopped',
|
|
'reconnect applies current stop',
|
|
)
|
|
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')
|
|
await poll(
|
|
async () => (await state()).channels.main.status === 'playing',
|
|
'reopen applies server play',
|
|
)
|
|
version++
|
|
await poll(
|
|
async () => (await state()).channels.main.status === 'playing',
|
|
'new command after incident',
|
|
)
|
|
remoteMode = 6
|
|
currentCommand = 0
|
|
version++
|
|
await poll(async () => !!(await state()).results, 'results refresh automatically')
|
|
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,
|
|
)
|
|
}
|
|
// Новые серверные плейлисты автоматически сохраняются на диск до включения.
|
|
music = {
|
|
stateVersion: '1',
|
|
playlist: {
|
|
ID: 1,
|
|
name: 'Автомузыка',
|
|
tracks: [{ ID: 41, name: 'Первый', filename: 'music-a.wav' }],
|
|
},
|
|
trackIndex: 0,
|
|
playback: 2,
|
|
volume: 40,
|
|
loop: true,
|
|
}
|
|
await poll(
|
|
async () => (await state()).channels.music.status === 'playing',
|
|
'initial music automatically cached',
|
|
)
|
|
const oldMusic = (await state()).channels.music.source.url
|
|
holdTrack = true
|
|
music = {
|
|
...music,
|
|
stateVersion: '2',
|
|
playlist: {
|
|
...music.playlist,
|
|
tracks: [...music.playlist.tracks, { ID: 42, name: 'Новый', filename: 'music-b.wav' }],
|
|
},
|
|
trackIndex: 1,
|
|
}
|
|
await poll(() => trackRequested, 'missing server track download starts')
|
|
assert.equal((await state()).channels.music.source.url, oldMusic)
|
|
remoteMode = 8
|
|
remoteText = 'Текст не ждёт музыку'
|
|
currentCommand = 0
|
|
version++
|
|
await poll(
|
|
async () => (await state()).scene.text === remoteText,
|
|
'scene independent from pending music',
|
|
)
|
|
holdTrack = false
|
|
releaseTrack()
|
|
await poll(
|
|
async () =>
|
|
(await state()).channels.music.trackIndex === 1 &&
|
|
(await state()).channels.music.status === 'playing',
|
|
'downloaded track plays from disk',
|
|
)
|
|
assert.match((await state()).channels.music.source.url, /^screen-app:/)
|
|
assert.notEqual((await state()).channels.music.source.url, oldMusic)
|
|
introAudio = ''
|
|
remoteMode = 2
|
|
currentCommand = 0
|
|
version++
|
|
await poll(
|
|
async () => (await state()).scene.mode === 2 && !(await state()).channels.main.source,
|
|
'screen without intro accepted',
|
|
)
|
|
await poll(
|
|
() => inspect(`document.body.textContent.includes('Вступление не задано')`),
|
|
'missing intro message',
|
|
)
|
|
assert.equal((await state()).channels.music.desired, 'playing')
|
|
console.log('SCREEN_LIVE_MATERIALS_SMOKE_PASS')
|
|
// Настоящий редактор: черновик, ошибка сохранения, порядок и переходы.
|
|
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 poll(async () => (await state()).timer.status === 'running', 'plan timer resumes')
|
|
await clickPlan('Сбросить')
|
|
await poll(async () => (await state()).timer.status === 'stopped', 'plan timer resets')
|
|
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')
|
|
const clickMusic = async (label) =>
|
|
poll(
|
|
() =>
|
|
evaluate(`(() => {
|
|
const button = Array.from(document.querySelectorAll('[data-testid="music-plan-editor"] button')).find(b => b.textContent.trim() === ${JSON.stringify(label)})
|
|
if (!button || button.disabled) return false
|
|
button.click(); return true
|
|
})()`),
|
|
`music button ${label}`,
|
|
)
|
|
const choosePlaylist = async (name) => {
|
|
await poll(
|
|
() =>
|
|
evaluate(
|
|
`(() => { const select = document.querySelector('[data-testid="music-plan-editor"] .n-base-selection'); return select && !select.classList.contains('n-base-selection--disabled') })()`,
|
|
),
|
|
'library selector ready',
|
|
)
|
|
await evaluate(
|
|
`document.querySelector('[data-testid="music-plan-editor"] .n-base-selection').click()`,
|
|
)
|
|
await poll(
|
|
() =>
|
|
evaluate(
|
|
`Array.from(document.querySelectorAll('.n-base-select-option')).some(e => e.textContent.trim() === ${JSON.stringify(name)})`,
|
|
),
|
|
'library option',
|
|
)
|
|
await evaluate(
|
|
`Array.from(document.querySelectorAll('.n-base-select-option')).find(e => e.textContent.trim() === ${JSON.stringify(name)}).click()`,
|
|
)
|
|
}
|
|
const confirmDelete = async (title) => {
|
|
await poll(
|
|
() =>
|
|
evaluate(`(() => {
|
|
const dialog = Array.from(document.querySelectorAll('.n-dialog')).find(d => d.textContent.includes(${JSON.stringify(title)}))
|
|
const button = dialog && Array.from(dialog.querySelectorAll('button')).find(b => b.textContent.trim() === 'Удалить')
|
|
if (!button || button.disabled) return false
|
|
button.click(); return true
|
|
})()`),
|
|
'delete confirmation ' + title,
|
|
)
|
|
}
|
|
await poll(
|
|
async () => (await evaluate('window.screenBridge.onlineState()')).playlists.length > 0,
|
|
'library loaded automatically',
|
|
)
|
|
await setInput('#new-playlist-name', 'Создан из приложения')
|
|
await clickMusic('Создать плейлист')
|
|
await poll(() => library.some((p) => p.name === 'Создан из приложения'), 'playlist created')
|
|
await poll(
|
|
() => evaluate(`document.querySelector('#playlist-name')?.value === 'Создан из приложения'`),
|
|
'new playlist selected',
|
|
)
|
|
await setInput('#playlist-name', 'Переименован')
|
|
// Повторный snapshot не затирает ввод названия.
|
|
await new Promise((resolve) => setTimeout(resolve, 650))
|
|
assert.equal(await evaluate(`document.querySelector('#playlist-name').value`), 'Переименован')
|
|
await clickMusic('Сохранить название')
|
|
await poll(() => library.some((p) => p.name === 'Переименован'), 'playlist renamed')
|
|
const uploadDirectory = mkdtempSync(join(tmpdir(), 'screen-mp3-smoke-'))
|
|
const originalDialog = dialog.showOpenDialog
|
|
try {
|
|
const file = join(uploadDirectory, 'native.mp3')
|
|
writeFileSync(file, 'ID3test')
|
|
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [file] })
|
|
await clickMusic('Добавить MP3')
|
|
await poll(
|
|
() =>
|
|
evaluate(
|
|
`document.querySelector('[data-testid="music-plan-editor"] ol')?.textContent.includes('native.mp3')`,
|
|
),
|
|
'native MP3 uploaded',
|
|
)
|
|
} finally {
|
|
dialog.showOpenDialog = originalDialog
|
|
rmSync(uploadDirectory, { recursive: true, force: true })
|
|
}
|
|
await clickMusic('Удалить трек')
|
|
await confirmDelete('Удалить трек?')
|
|
await poll(
|
|
() =>
|
|
evaluate(
|
|
`!document.querySelector('[data-testid="music-plan-editor"] ol')?.textContent.includes('native.mp3')`,
|
|
),
|
|
'track removed',
|
|
)
|
|
await clickMusic('Удалить плейлист')
|
|
await confirmDelete('Удалить плейлист?')
|
|
await poll(() => !library.some((p) => p.name === 'Переименован'), 'playlist removed')
|
|
await choosePlaylist('Редактор музыки')
|
|
await clickMusic('Использовать в игре')
|
|
await poll(
|
|
async () => (await state()).channels.music.playlistId === 9,
|
|
'playlist assigned to game',
|
|
)
|
|
music = { ...music, playback: 2, stateVersion: String(Number(music.stateVersion) + 1) }
|
|
await poll(
|
|
async () => (await state()).channels.music.status === 'playing',
|
|
'assigned music plays',
|
|
)
|
|
const beforeOrder = (await state()).channels.music
|
|
await evaluate(`document.querySelector('[aria-label="Опустить трек 1"]').click()`)
|
|
assert.deepEqual(
|
|
library[0].tracks.map((t) => t.ID),
|
|
[91, 92],
|
|
'draft not published',
|
|
)
|
|
await clickMusic('Отменить')
|
|
await evaluate(`document.querySelector('[aria-label="Опустить трек 1"]').click()`)
|
|
await clickMusic('Сохранить порядок')
|
|
await poll(
|
|
async () => (await state()).channels.music.trackIndex === 1,
|
|
'order applied without changing file',
|
|
)
|
|
const afterOrder = (await state()).channels.music
|
|
assert.equal(afterOrder.source.url, beforeOrder.source.url)
|
|
assert.equal(afterOrder.operation, beforeOrder.operation)
|
|
assert.equal(afterOrder.seekOperation, beforeOrder.seekOperation)
|
|
assert.equal(afterOrder.desired, 'playing')
|
|
assert.ok(afterOrder.position >= beforeOrder.position)
|
|
console.log('SCREEN_MUSIC_PLAN_SMOKE_PASS')
|
|
await act({ action: 'disconnect' })
|
|
await poll(
|
|
() =>
|
|
evaluate(`(() => {
|
|
const select = document.querySelector('.connection-content .n-base-selection')
|
|
select?.dispatchEvent(new MouseEvent('mouseenter'))
|
|
const clear = select?.querySelector('.n-base-clear__clear')
|
|
if (!clear) return false
|
|
clear.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
clear.click(); return true
|
|
})()`),
|
|
'clear game selection',
|
|
)
|
|
await poll(
|
|
() =>
|
|
evaluate(
|
|
`document.querySelector('.connection-content .n-base-selection')?.textContent.includes('Выберите игру')`,
|
|
),
|
|
'server selection cleared',
|
|
)
|
|
await poll(
|
|
() =>
|
|
evaluate(`(() => {
|
|
const button = Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Подключить')
|
|
if (!button || button.disabled) return false
|
|
button.click(); return true
|
|
})()`),
|
|
'connect current prepared game',
|
|
)
|
|
await poll(
|
|
async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected',
|
|
'current game reconnected',
|
|
)
|
|
assert.equal((await state()).catalog.name, 'HTTP smoke')
|
|
|
|
await act({ action: 'logout' })
|
|
assert.equal((await evaluate('window.screenBridge.onlineState()')).authenticated, false)
|
|
await poll(
|
|
() =>
|
|
evaluate(
|
|
`document.querySelector('[data-testid="control-game"]')?.textContent.includes('HTTP smoke')`,
|
|
),
|
|
'current local game stays visible after logout',
|
|
)
|
|
console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length }))
|
|
} finally {
|
|
server.closeAllConnections()
|
|
await new Promise((resolve) => server.close(resolve))
|
|
}
|
|
}
|