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, session } = 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 }, ], }, { ID: 10, name: 'Тестовый плейлист', tracks: [] }, ] let nextPlaylistId = 11 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()) : {} assert.ok( req.url.startsWith('http://screen-proxy.invalid/'), 'API and media use Chromium proxy', ) const path = new URL(req.url).pathname 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', status: 'finish', 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/cached-intro.wav', '/api/files/music-a.wav', '/api/files/music-b.wav', ].includes(path) ) { if (path === '/api/files/intro.wav') { res.writeHead(302, { Location: '/api/files/cached-intro.wav' }) return res.end() } 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://screen-proxy.invalid' await session.defaultSession.setProxy({ mode: 'fixed_servers', proxyRules: `http=127.0.0.1:${server.address().port}`, proxyBypassRules: '<-loopback>', }) await session.defaultSession.closeAllConnections() const act = (intent) => evaluate(`window.screenBridge.onlineAction(${JSON.stringify(intent)})`) const checkBadges = (authenticated, connected) => poll( () => evaluate(`(() => { const auth = document.querySelectorAll('.n-card-header [data-testid="auth-status"]') const game = document.querySelectorAll('[data-testid="game-control"] [data-testid="game-connection-status"]') return auth.length === 1 && game.length === 1 && auth[0].textContent.trim() === '${authenticated ? 'Вход выполнен' : 'Не выполнен'}' && game[0].textContent.trim() === '${connected ? 'Подключено' : 'Не подключено'}' })()`), 'auth and game badges reflect separate states', ) try { await evaluate('window.screenBridge.closeOutput()') // Вход через настоящую форму пульта, загрузка через защищённый IPC. await poll(() => evaluate('!!document.querySelector("#server")'), 'login form') await checkBadges(false, false) 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 evaluate(`Array.from(document.querySelectorAll('button')) .find(b => b.textContent.trim() === 'Сменить игру')?.click()`) 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 === 5 && document.querySelectorAll('[data-testid="game-control"] .n-card').length === 0 && document.querySelectorAll('.n-card-header__main').length === 6`, ), 'six control cards with merged game controls', ) assert.deepEqual( await evaluate( `Array.from(document.querySelectorAll('.n-card-header__main')).map(e => (e.querySelector('.server-heading > span:first-child') ?? 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.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)})`, ), false, 'removed button: ' + label, ) varyGames = true for (let refresh = 0; refresh < 5; refresh++) { await poll( () => evaluate( `!document.querySelector('button[aria-label="Обновить список игр"]')?.disabled`, ), 'refresh games button ready', ) const previousCalls = gamesCalls await evaluate( `document.querySelector('button[aria-label="Обновить список игр"]').click()`, ) await poll( async () => gamesCalls > previousCalls && !(await evaluate('window.screenBridge.onlineState()')).busy, 'refresh games complete', ) await checkBadges(true, false) } await poll( () => evaluate( `!document.querySelector('button[aria-label="Обновить список игр"]')?.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 assert.equal((await state()).catalog.key, null, 'server selection does not prepare a game') assert.equal( await evaluate(`!!document.querySelector('[data-testid="control-game"]')`), false, 'current game stays empty until connection', ) 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 buttons = Array.from(document.querySelectorAll('button')) const select = buttons.find(b => b.textContent.trim() === 'Выбрать игру') const connect = buttons.find(b => b.textContent.trim() === 'Подключиться') if (!select || select.disabled || !connect?.disabled) return false select.click(); return true })()`), 'confirm selected game before screen connection', ) await poll( async () => { const online = await evaluate('window.screenBridge.onlineState()') return !online.busy && online.currentGameId === 6 && online.screenId === null && online.materials.every(m => m.status === 'ready') }, 'game prepared without screen connection', ) 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', ) await checkBadges(true, true) await poll( () => evaluate(`(() => { const content = document.querySelector('.connection-content') const buttons = Array.from(document.querySelector('[data-testid="game-connection-status"]')?.closest('.n-card')?.querySelectorAll('button') ?? []) return !content?.querySelector('.game-selection') && !buttons.some(b => b.textContent.trim() === 'Подключиться') && buttons.some(b => b.textContent.trim() === 'Отключиться' && b.closest('.n-space')?.textContent.includes('Экран №')) })()`), 'selected game hides selector and connect button, disconnect follows screen name', ) assert.equal((await state()).outputOpen, false) await evaluate(`window.screenBridge.show(${displayId})`) await poll( async () => (await evaluate('window.screenBridge.onlineState()')).connection === 'connected', 'HTTP baseline', ) assert.equal( await evaluate(`(() => { const assignment = document.querySelector('[data-testid="sound-assignment"]') const card = assignment?.closest('.n-card') return card?.querySelector('.n-card-header__main')?.textContent.trim() === 'Игра' && ['Назначить звук этому экрану', 'Отключиться'].every(label => !Array.from(document.querySelectorAll('.n-card')).find(c => c.querySelector('.n-card-header__main')?.textContent.trim() === 'Экран')?.textContent.includes(label)) })()`), true, 'server screen controls belong to game screen card', ) 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 checkBadges(true, false) 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.querySelector('[data-testid="screen-plan"]').closest('.n-card').querySelectorAll('button')) .find(b => (b.getAttribute('aria-label') ?? 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"] [data-testid="show-step"]').click()`) await poll( async () => (await state()).scene.text === 'Текст из редактора' && (await state()).scene.stepIndex === 1, 'show text step', ) await evaluate(`document.querySelector('[data-step-index="2"] [data-testid="show-step"]').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.querySelector('[data-testid="music-plan"]').querySelectorAll('button')).find(b => (b.getAttribute('aria-label') ?? 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 clickMusic('Редактировать') assert.equal( await evaluate(`!!document.querySelector('#new-playlist-name') || Array.from(document.querySelectorAll('[data-testid="music-plan"] button')) .some(b => b.textContent.trim() === 'Создать плейлист')`), false, 'playlist creation form is absent', ) await choosePlaylist('Тестовый плейлист') 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"] .track-list')?.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"] .track-list')?.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) await clickMusic('Готово') await poll( () => evaluate(`(() => { const view = document.querySelector('[data-testid="music-plan-view"]') const rows = Array.from(view?.querySelectorAll('[data-track-index]') ?? []) return rows.length === 2 && !document.querySelector('[data-testid="music-plan-editor"]') && rows.every(row => !row.querySelector('.track-actions,.n-progress,.n-slider') && row.querySelector('[data-testid="select-track"]')) && view.querySelectorAll('.track-actions').length === 1 && view.querySelectorAll('.n-progress').length === 1 && view.querySelectorAll('#volume-music').length === 1 && !!view.querySelector('[data-testid="current-music-track"]') })()`), 'music view shows track selection and one shared player above tracks', ) console.log('SCREEN_MUSIC_PLAN_SMOKE_PASS') await evaluate(`Array.from(document.querySelectorAll('button')) .find(b => b.textContent.trim() === 'Отключиться').click()`) await poll( () => evaluate(`(() => { const card = document.querySelector('[data-testid="game-connection-status"]')?.closest('.n-card') return card?.textContent.includes('Не подключено') && card.textContent.includes('Подключиться') && !card.textContent.includes('Отключиться') && !document.querySelector('.game-selection') })()`), 'disconnect keeps selected game and restores connect in game screen card', ) 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' }) await checkBadges(false, false) 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', ) const packageKeys = (await evaluate('window.screenBridge.packages()')).map((pkg) => pkg.key) await evaluate(`Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Сменить игру').click()`) await poll( async () => (await state()).catalog.key === null, 'reset clears current game', ) await poll( () => evaluate(`!document.querySelector('[data-testid="control-game"]')`), 'reset hides current game row', ) assert.deepEqual((await evaluate('window.screenBridge.packages()')).map((pkg) => pkg.key), packageKeys) assert.ok(requests.includes('/api/auth/login'), 'login through proxy') assert.ok(requests.includes('/api/files/cached-intro.wav'), 'redirected media through proxy') console.log('SCREEN_PROXY_SMOKE_PASS') console.log('SCREEN_ONLINE_SMOKE_PASS', JSON.stringify({ polls, requests: requests.length })) } finally { await session.defaultSession.setProxy({ mode: 'system' }) await session.defaultSession.closeAllConnections() server.closeAllConnections() await new Promise((resolve) => server.close(resolve)) } }