const assert = require('node:assert/strict') const { app, BrowserWindow, screen, nativeImage, powerSaveBlocker } = require('electron') const { join, resolve } = require('node:path') const { mkdtempSync, readFileSync, writeFileSync } = require('node:fs') const root = resolve(__dirname, '..') app.setAppPath(root) app.setPath('userData', mkdtempSync(join(require('node:os').tmpdir(), 'screen-smoke-'))) const manual = process.argv.includes('--manual') const delay = (ms) => new Promise((r) => setTimeout(r, ms)) let targetDisplay let isSyntheticDisplay = false const boundsBeforeFullscreen = new Map() const fullscreenEntered = new Set() const powerBlocks = { active: new Set(), starts: 0, stops: 0 } const startBlocker = powerSaveBlocker.start.bind(powerSaveBlocker) const stopBlocker = powerSaveBlocker.stop.bind(powerSaveBlocker) powerSaveBlocker.start = (type) => { assert.equal(type, 'prevent-display-sleep') const id = startBlocker(type) powerBlocks.active.add(id) powerBlocks.starts++ return id } powerSaveBlocker.stop = (id) => { assert.ok(powerBlocks.active.delete(id), 'blocker released exactly once') powerBlocks.stops++ return stopBlocker(id) } const captures = [] app.on('browser-window-created', (_event, window) => { window.webContents.on('render-process-gone', (_event, details) => console.log('SMOKE_RENDERER_GONE', window.id, details.reason), ) const capturePage = window.webContents.capturePage.bind(window.webContents) window.webContents.capturePage = (...args) => { captures.push({ id: window.id, time: performance.now() }) return capturePage(...args) } window.on('enter-full-screen', () => fullscreenEntered.add(window.id)) let requestedBounds const setBounds = window.setBounds.bind(window) window.setBounds = (bounds, ...args) => { requestedBounds = { ...bounds } return setBounds(bounds, ...args) } const setFullScreen = window.setFullScreen.bind(window) window.setFullScreen = (flag) => { if (flag) boundsBeforeFullscreen.set(window.id, requestedBounds) // WM не знает синтетический монитор: native fullscreen проверяем только на реальном. if (isSyntheticDisplay) { queueMicrotask(() => { if (!window.isDestroyed()) window.emit(flag ? 'enter-full-screen' : 'leave-full-screen') }) return } return setFullScreen(flag) } }) app.whenReady().then(() => { const original = screen.getAllDisplays.bind(screen) const displays = original() targetDisplay = displays.find((display) => display.id !== screen.getPrimaryDisplay().id) if (!targetDisplay && manual) { console.error('Подключите внешний дисплей в режиме расширенного рабочего стола') app.exit(1) return } if (!targetDisplay) { isSyntheticDisplay = true targetDisplay = { ...displays[0], id: 424242, label: 'Synthetic test display', bounds: { x: 1280, y: 0, width: 1280, height: 720 }, size: { width: 1280, height: 720 }, } screen.getAllDisplays = () => [...original(), targetDisplay] } }) function musicWav(frequency) { const size = 48000 * 2 * 4 const bytes = Buffer.alloc(44 + size) bytes.write('RIFF', 0) bytes.writeUInt32LE(36 + size, 4) bytes.write('WAVEfmt ', 8) bytes.writeUInt32LE(16, 16) bytes.writeUInt16LE(1, 20) bytes.writeUInt16LE(1, 22) bytes.writeUInt32LE(48000, 24) bytes.writeUInt32LE(96000, 28) bytes.writeUInt16LE(2, 32) bytes.writeUInt16LE(16, 34) bytes.write('data', 36) bytes.writeUInt32LE(size, 40) for (let sample = 0; sample < size / 2; sample++) bytes.writeInt16LE( Math.round(Math.sin((2 * Math.PI * frequency * sample) / 48000) * 2000), 44 + sample * 2, ) return bytes } let offlinePackage const seeded = (async () => { const { PackageStore } = require(root + '/dist-electron/electron/package-store.js') const { buildScreenPackage } = require(root + '/dist-electron/electron/screen-package.js') const audio = readFileSync(root + '/fixtures/audio.wav') const video = readFileSync(root + '/fixtures/video.mp4') const image = (color) => nativeImage .createFromBitmap(Buffer.alloc(320 * 180 * 4).fill(Buffer.from(color)), { width: 320, height: 180, }) .toPNG() const cover = image([30, 60, 80, 255]) const background = image([70, 40, 20, 255]) const pkg = buildScreenPackage('https://offline.invalid', 7, { game: { game: { ID: 7, name: 'Offline smoke', scenario: { image: 'cover.png', story: { background: 'background.png', introduction: { audio: 'intro.wav' }, conclusion: { video: 'ending.mp4' }, }, }, }, }, plan: { plan: { steps: Array.from({ length: 8 }, (_, index) => ({ mode: index + 1, text: index === 7 ? 'Привет, детективы!\nКириллица: Ёжик и улика №7' : '', timerLabel: 'Перерыв', timerDurationSeconds: index === 6 ? '2' : '0', })), }, }, music: { gamePlaylistId: 1, playlists: [ { ID: 1, name: 'Offline playlist', tracks: [ { ID: 1, name: 'One', filename: 'one.wav', position: 0 }, { ID: 2, name: 'Two', filename: 'two.wav', position: 1 }, ], }, ], }, }) const store = new PackageStore(join(app.getPath('userData'), 'packages'), { fetch: async (input) => { const url = String(input) const bytes = url.endsWith('cover.png') ? cover : url.endsWith('background.png') ? background : url.endsWith('intro.wav') ? audio : url.endsWith('.mp4') ? video : musicWav(url.endsWith('two.wav') ? 660 : 330) return new Response(bytes, { headers: { 'Content-Type': url.endsWith('.png') ? 'image/png' : url.endsWith('.mp4') ? 'video/mp4' : 'audio/wav', 'Content-Length': String(bytes.length), }, }) }, }) await store.initialize() offlinePackage = await store.download(pkg) })() seeded.catch((error) => { console.error('PACKAGE_SEED_FAIL', error) app.exit(1) }) // Регистрация протокола до ready; новый экземпляр хранилища открывается после seed. const whenReady = app.whenReady.bind(app) app.whenReady = () => whenReady().then(() => seeded) require(root + '/dist-electron/electron/main.js') app.whenReady = whenReady async function poll(check, label) { for (let i = 0; i < 150; i++) { const result = await check() if (result) return result await delay(100) } throw new Error('Timeout: ' + label) } app.whenReady().then(async () => { try { await seeded const control = await poll( () => BrowserWindow.getAllWindows().find( (w) => w.webContents.getURL() === 'screen-app://app/index.html', ), 'control', ) if (process.platform === 'linux') assert.ok(control.getNativeWindowHandle().readUInt32LE(0) > 0xffff, 'native X11 window ID') control.webContents.on('console-message', (_event, details) => console.log('renderer:', details.message), ) const evaluate = (source) => control.webContents.executeJavaScript(source) await poll( async () => !control.webContents.isLoading() && (await evaluate('!!window.screenBridge && !!document.querySelector("main.control")')), 'control UI', ) if (process.argv.includes('--materials-prepare')) { await require('./materials-prepare-smoke.cjs')({ evaluate, poll }) app.exit(0) return } if (process.argv.includes('--clear-games')) { await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`) await poll(() => evaluate('!!document.querySelector("[data-testid=clear-games]:not([disabled])")'), 'clear games button') const clickDialog = (label) => evaluate(`Array.from(document.querySelectorAll('.n-dialog__action button')).find(b => b.textContent.trim() === ${JSON.stringify(label)}).click()`) await evaluate('document.querySelector("[data-testid=clear-games]").click()') await poll(() => evaluate('!!document.querySelector(".n-dialog")'), 'clear games confirmation') await clickDialog('Отмена') assert.equal((await evaluate('window.screenBridge.packages()')).length, 1) await poll(() => evaluate('!document.querySelector(".n-dialog")'), 'clear games cancelled') await evaluate('document.querySelector("[data-testid=clear-games]").click()') await poll(() => evaluate('!!document.querySelector(".n-dialog")'), 'clear games confirmation again') await clickDialog('Удалить всё') await poll(async () => (await evaluate('window.screenBridge.packages()')).length === 0, 'packages cleared') await poll(() => evaluate('document.body.textContent.includes("Скачанных игр пока нет")'), 'empty local games UI') assert.equal((await evaluate('window.screenBridge.state()')).catalog.key, null) console.log('SCREEN_CLEAR_GAMES_PASS') app.exit(0) return } if (manual) { await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`) console.log( 'SCREEN004_ACCEPTANCE_READY', JSON.stringify({ userData: app.getPath('userData'), electron: process.versions.electron }), ) return } const state = () => evaluate('window.screenBridge.state()') const command = async (intent) => { const sequence = await evaluate( `(async () => { const state = await window.screenBridge.state(); const sequence = state.sequence + 1; await window.screenBridge.command({ session: state.session, sequence, ...${JSON.stringify(intent)} }); return sequence })()`, ) return poll(async () => { const s = await state() const c = intent.channel ? s.channels[intent.channel] : null if (c?.status === 'error') throw new Error(c.error) return s.sequence >= sequence && (!c || c.status !== 'pending') && s }, JSON.stringify(intent)) } if (process.argv.includes('--sound-check')) { const devices = BrowserWindow.getAllWindows().find((w) => w.webContents.getURL().includes('audio-devices=1')) const inspectAudio = (source) => devices.webContents.executeJavaScript(source) await command({ action: 'mute', enabled: true }) await command({ action: 'volume', channel: 'main', volume: 37 }) await command({ action: 'volume', channel: 'music', volume: 29 }) const before = await state() await poll(() => evaluate('!!document.querySelector("[data-testid=check-sound]:not([disabled])")'), 'sound check enabled') await evaluate('document.querySelector("[data-testid=check-sound]").click()') await poll(() => inspectAudio('!document.querySelector("audio").paused'), 'test signal playing without output') assert.equal(await evaluate('document.querySelector("[data-testid=check-sound]").disabled'), true) assert.equal(await evaluate('!!document.querySelector("#audio-output .n-base-selection--disabled")'), true) await assert.rejects(evaluate('window.screenBridge.checkSound()'), /уже выполняется/) await assert.rejects(evaluate('window.screenBridge.selectAudioOutput("")'), /проверки звука/) assert.equal(await inspectAudio('document.querySelector("audio").muted'), false) await poll(() => evaluate('!document.querySelector("[data-testid=check-sound]").disabled'), 'test signal completed') assert.equal(await inspectAudio('document.querySelector("audio").getAttribute("src")'), null) assert.equal((await state()).outputOpen, false) assert.equal((await state()).muted, true) assert.deepEqual((await state()).channels, before.channels) const audioHost = await evaluate('window.screenBridge.host()') if (audioHost.audioOutputs.length) { const id = audioHost.audioOutputs[0].id await evaluate(`window.screenBridge.selectAudioOutput(${JSON.stringify(id)})`) await evaluate('window.screenBridge.checkSound()') assert.equal(await inspectAudio('document.querySelector("audio").sinkId'), id) } await evaluate(`window.screenBridge.show(${targetDisplay.id})`) await poll(async () => (await state()).channels.main.status === 'stopped', 'output prepared') await command({ action: 'play', channel: 'main' }) const playing = (await state()).channels.main await evaluate('window.screenBridge.checkSound()') const after = (await state()).channels.main assert.equal(after.desired, 'playing') assert.equal(after.operation, playing.operation) assert.equal(after.volume, playing.volume) assert.deepEqual(after.source, playing.source) assert.equal((await state()).muted, true) const outputWindow = BrowserWindow.getAllWindows().find((w) => w.webContents.getURL().includes('output=1')) await assert.rejects(outputWindow.webContents.executeJavaScript('window.screenBridge.checkSound()'), /Недоступно/) await inspectAudio('window.originalSetSinkId = HTMLMediaElement.prototype.setSinkId; HTMLMediaElement.prototype.setSinkId = async () => { throw new Error("TEST_MISSING_DEVICE") }; void 0') await assert.rejects(evaluate('window.screenBridge.checkSound()'), /TEST_MISSING_DEVICE/) await inspectAudio('HTMLMediaElement.prototype.setSinkId = window.originalSetSinkId; void 0') await inspectAudio('window.originalPlay = HTMLMediaElement.prototype.play; HTMLMediaElement.prototype.play = () => Promise.resolve(); void 0') await assert.rejects(evaluate('window.screenBridge.checkSound()'), /Превышено время проверки/) assert.equal(await inspectAudio('document.querySelector("audio").getAttribute("src")'), null) await inspectAudio('HTMLMediaElement.prototype.play = window.originalPlay; void 0') await evaluate('window.screenBridge.checkSound()') console.log('SCREEN_SOUND_CHECK_PASS') app.exit(0) return } assert.equal(await evaluate('typeof window.require'), 'undefined') assert.equal((await evaluate('window.screenBridge.packages()')).length, 1) if (process.argv.includes('--intro-waveform')) await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`) await evaluate(`window.screenBridge.show(${targetDisplay.id})`) let output = await poll( () => BrowserWindow.getAllWindows().find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1'), 'output', ) const inspect = (source) => BrowserWindow.getAllWindows() .find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1') .webContents.executeJavaScript(source) if (process.argv.includes('--intro-waveform')) { const selectedDevice = (await evaluate('window.screenBridge.host()')).audioOutputs .find(device => device.id && !['default', 'communications'].includes(device.id)) if (selectedDevice) await evaluate(`window.screenBridge.selectAudioOutput(${JSON.stringify(selectedDevice.id)})`) await command({ action: 'step', index: 1 }) await poll(() => inspect('document.querySelector(".intro-waveform")?.getAttribute("aria-valuenow") === "0"'), 'intro waveform starts empty') assert.equal(await inspect('document.querySelectorAll(".intro-recording button,.intro-recording input").length'), 0) assert.equal(await inspect('document.querySelectorAll(".intro-waveform rect").length'), 128) assert.equal(await inspect('document.querySelector(".intro-recording").textContent.trim()'), '') assert.equal(await inspect('getComputedStyle(document.querySelector(".intro-recording")).backgroundColor'), 'rgba(0, 0, 0, 0)') const bars = () => inspect('Array.from(document.querySelectorAll(".intro-waveform rect"), r => Number(r.getAttribute("height")))') const sinkBefore = await inspect('document.querySelector("audio[data-channel=main-audio]").sinkId') await command({ action: 'play', channel: 'main' }) await poll(async () => (await bars()).some(h => h > 3), 'wave reacts to audio') const playingBars = JSON.stringify(await bars()) await poll(async () => JSON.stringify(await bars()) !== playingBars, 'wave changes with audio') await command({ action: 'mute', enabled: true }) await poll(async () => (await bars()).every(h => h === 2), 'mute shows silence') await command({ action: 'mute', enabled: false }) await poll(async () => (await bars()).some(h => h > 3), 'unmute restores wave') assert.equal(await inspect('document.querySelector("audio[data-channel=main-audio]").sinkId'), sinkBefore) await command({ action: 'seek', channel: 'main', position: 15 }) await command({ action: 'pause', channel: 'main' }) await poll(() => inspect('Number(document.querySelector(".intro-waveform").getAttribute("aria-valuenow")) >= 24'), 'intro progress follows seek') await poll(async () => (await state()).channels.main.status === 'paused', 'intro paused') const pausedBars = await bars() await delay(400) assert.deepEqual(await bars(), pausedBars) writeFileSync('/tmp/screen-intro-waveform.png', (await output.webContents.capturePage()).toPNG()) for (const [width, height] of [[1280, 720], [1024, 768]]) { output.webContents.enableDeviceEmulation({ screenPosition: 'desktop', screenSize: { width, height }, viewSize: { width, height }, deviceScaleFactor: 1, scale: 1 }) await poll(() => inspect(`innerWidth === ${width} && innerHeight === ${height}`), 'intro aspect ratio') assert.ok(await inspect('(() => { const r = document.querySelector(".intro-waveform").getBoundingClientRect(); return Math.abs(r.x + r.width / 2 - innerWidth / 2) < 1 && Math.abs(r.y + r.height / 2 - innerHeight / 2) < 1 })()')) } output.webContents.disableDeviceEmulation() await evaluate('window.screenBridge.selectAudioOutput("")') const pausedPosition = await inspect('document.querySelector("audio[data-channel=main-audio]").currentTime') await command({ action: 'resume', channel: 'main' }) await poll(() => inspect(`document.querySelector('audio[data-channel=main-audio]').currentTime > ${pausedPosition + 0.3}`), 'intro resumes') await command({ action: 'pause', channel: 'main' }) output.webContents.enableDeviceEmulation({ screenPosition: 'desktop', screenSize: { width: 320, height: 240 }, viewSize: { width: 320, height: 240 }, deviceScaleFactor: 1, scale: 1 }) await poll(() => inspect('window.innerWidth === 320 && window.innerHeight === 240'), 'small output viewport') assert.ok(await inspect('(() => { const r = document.querySelector(".intro-recording").getBoundingClientRect(); return r.width <= 320 && r.height <= 240 })()')) output.webContents.disableDeviceEmulation() await command({ action: 'replay', channel: 'main' }) await poll(() => inspect('Number(document.querySelector(".intro-waveform").getAttribute("aria-valuenow")) < 5'), 'intro replay resets progress') await command({ action: 'seek', channel: 'main', position: 59.7 }) await poll(async () => (await state()).channels.main.status === 'ended', 'intro audio ends') await poll(() => inspect('document.querySelector(".intro-waveform").getAttribute("aria-valuenow") === "100"'), 'intro accessible progress reaches end') assert.ok((await bars()).every(h => h === 2)) await command({ action: 'step', index: 2 }) await poll(() => inspect('!document.querySelector(".intro-recording")'), 'waveform hidden in other modes') await command({ action: 'step', index: 1 }) await poll(() => inspect('document.querySelector(".intro-waveform")?.getAttribute("aria-valuenow") === "0"'), 'return to intro resets progress') await inspect('const a = document.querySelector("audio[data-channel=main-audio]"); Object.defineProperty(a, "duration", { value: NaN, configurable: true }); a.dispatchEvent(new Event("durationchange"))') await poll(() => inspect('document.querySelector(".intro-waveform").getAttribute("aria-valuetext") === "00:00 / —"'), 'unknown duration') await inspect('delete document.querySelector("audio[data-channel=main-audio]").duration') await evaluate('window.screenBridge.closeOutput()') await evaluate('window.screenBridge.selectPackage(null)') await evaluate(`window.screenBridge.show(${targetDisplay.id})`) await poll(() => BrowserWindow.getAllWindows().some((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1' && !w.webContents.isLoading()), 'new output ready') await command({ action: 'mode', mode: 2 }) await poll(() => inspect('document.querySelector(".intro-waveform")?.getAttribute("aria-valuenow") === "0"'), 'new game starts empty') await inspect('window.originalCaptureStream = HTMLMediaElement.prototype.captureStream; HTMLMediaElement.prototype.captureStream = () => { throw new Error("unavailable") }; void 0') await command({ action: 'play', channel: 'main' }) await poll(async () => (await state()).channels.main.status === 'playing', 'visualization failure does not stop audio') await poll(async () => (await bars()).every(h => h === 2), 'visualization failure shows baseline') await inspect('HTMLMediaElement.prototype.captureStream = window.originalCaptureStream; void 0') console.log('SCREEN_INTRO_WAVEFORM_PASS') app.exit(0) return } await poll(async () => (await state()).channels.main.status === 'stopped', 'fixture prepared') if (process.platform === 'linux') assert.equal(app.commandLine.getSwitchValue('ozone-platform'), 'x11') async function checkDisplay(window) { await poll(() => boundsBeforeFullscreen.has(window.id), 'fullscreen requested') assert.deepEqual(boundsBeforeFullscreen.get(window.id), targetDisplay.bounds) if (!isSyntheticDisplay) { await poll(() => fullscreenEntered.has(window.id), 'fullscreen entered') assert.ok(window.isFullScreen()) assert.equal(screen.getDisplayMatching(window.getBounds()).id, targetDisplay.id) } } await checkDisplay(output) await command({ action: 'play', channel: 'main' }) await poll(async () => (await state()).channels.main.status === 'playing', 'fixture play') const audioHost = await evaluate('window.screenBridge.host()') assert.equal(await evaluate('!!document.querySelector("#audio-output")'), true) assert.equal(await evaluate('window.screenBridge.selectAudioOutput("missing").then(() => false, () => true)'), true) if (audioHost.audioOutputs.length) { const device = audioHost.audioOutputs[0].id const before = await inspect('document.querySelector("audio[data-channel=main-audio]").currentTime') await evaluate(`window.screenBridge.selectAudioOutput(${JSON.stringify(device)})`) assert.deepEqual(await inspect('Array.from(document.querySelectorAll("audio,video")).map(media => media.sinkId)'), [device, device, device, device]) assert.equal(await inspect('document.querySelector("audio[data-channel=main-audio]").paused'), false) assert.ok(await inspect('document.querySelector("audio[data-channel=main-audio]").currentTime') >= before) await evaluate('window.screenBridge.selectAudioOutput("")') assert.equal((await evaluate('window.screenBridge.host()')).audioOutputId, '') } await command({ action: 'pause', channel: 'main' }) await command({ action: 'resume', channel: 'main' }) await command({ action: 'stop', channel: 'main' }) await command({ action: 'select', channel: 'main', sourceId: 'fixture-video' }) await command({ action: 'play', channel: 'main' }) await command({ action: 'seek', channel: 'main', position: 30 }) const fixturePosition = await inspect('document.querySelector("video").currentTime') assert.ok(fixturePosition >= 29.9 && fixturePosition < 35) const range = await inspect( `fetch('screen-app://app/media/video', { headers: { Range: 'bytes=100-199' } }).then(async r => ({ status: r.status, size: (await r.arrayBuffer()).byteLength }))`, ) assert.deepEqual(range, { status: 206, size: 100 }) await command({ action: 'pause', channel: 'main' }) assert.equal(await evaluate('document.body.textContent.includes("Материалы показа")'), false) assert.equal(await evaluate('document.body.textContent.includes("Вступление и видео")'), false) await assert.rejects(evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)) await evaluate('window.screenBridge.closeOutput()') const localPackages = await evaluate('window.screenBridge.packages()') assert.equal(localPackages[0].downloadedFiles, localPackages[0].totalFiles) await poll(() => evaluate('!!document.querySelector("[data-testid=local-game-select] .n-base-selection")'), 'local game selector') await evaluate('document.querySelector("[data-testid=local-game-select] .n-base-selection").click()') await poll(() => evaluate('!!document.querySelector(".n-base-select-option")'), 'local game option') await evaluate('document.querySelector(".n-base-select-option").click()') await evaluate('document.querySelector("[data-testid=local-game-confirm]").click()') await poll(async () => (await state()).catalog.key === offlinePackage.key, 'local game selected') await evaluate(`window.screenBridge.show(${targetDisplay.id})`) await poll(async () => { const s = await state() return ( s.catalog.key === offlinePackage.key && s.channels.main.status === 'stopped' && s.channels.music.status === 'stopped' ) }, 'package prepared') await command({ action: 'play', channel: 'main' }) await command({ action: 'seek', channel: 'main', position: 15 }) await command({ action: 'pause', channel: 'main' }) const pausedAt = await inspect( 'document.querySelector("[data-channel=main-audio]").currentTime', ) await command({ action: 'resume', channel: 'main' }) assert.ok( await inspect( `document.querySelector('[data-channel=main-audio]').currentTime >= ${pausedAt - 0.1}`, ), ) await command({ action: 'replay', channel: 'main' }) assert.ok(await inspect("document.querySelector('[data-channel=main-audio]').currentTime < 2")) await command({ action: 'select', channel: 'main', sourceId: 'conclusion' }) await command({ action: 'play', channel: 'main' }) await command({ action: 'seek', channel: 'main', position: 30 }) await command({ action: 'play', channel: 'music' }) assert.equal( await inspect( "Array.from(document.querySelectorAll('audio,video')).filter(m => !m.paused).length", ), 2, ) await command({ action: 'volume', channel: 'music', volume: 70 }) const videoBeforeVolume = await inspect('document.querySelector("video").currentTime') await command({ action: 'volume', channel: 'main', volume: 25 }) assert.ok( await inspect(`document.querySelector('video').currentTime >= ${videoBeforeVolume - 0.1}`), ) await command({ action: 'mute', enabled: true }) assert.equal( await inspect( "Array.from(document.querySelectorAll('audio,video')).filter(m => !m.paused && !m.muted).length", ), 0, ) await command({ action: 'mute', enabled: false }) assert.equal(await inspect("document.querySelector('video').volume"), 0.25) await command({ action: 'seek', channel: 'music', position: 3.9 }) await poll(async () => { const c = (await state()).channels.music return c.trackIndex === 1 && c.status === 'playing' }, 'automatic next track') assert.equal( await inspect( "Array.from(document.querySelectorAll('audio[data-channel^=music]')).filter(m => !m.paused).length", ), 1, ) await command({ action: 'seek', channel: 'music', position: 3.9 }) await poll( async () => (await state()).channels.music.status === 'ended', 'playlist ends without repeat', ) await command({ action: 'loop', channel: 'music', enabled: true }) await command({ action: 'resume', channel: 'music' }) await command({ action: 'seek', channel: 'music', position: 3.9 }) await poll(async () => { const c = (await state()).channels.music return c.trackIndex === 0 && c.status === 'playing' }, 'playlist repeats') await command({ action: 'pause', channel: 'music' }) await command({ action: 'next', channel: 'music' }) assert.equal((await state()).channels.music.trackIndex, 1) assert.equal((await state()).channels.music.status, 'paused') await command({ action: 'stop', channel: 'main' }) await evaluate(`(async () => { const s = await window.screenBridge.state() await Promise.all([{action:'play'}, {action:'seek',position:20}, {action:'pause'}].map((intent,i) => window.screenBridge.command({session:s.session,sequence:s.sequence+i+1,channel:'main',...intent}))) })()`) await poll( async () => (await state()).channels.main.status === 'paused', 'rapid commands settle', ) assert.ok(await inspect('Math.abs(document.querySelector("video").currentTime - 20) < 0.3')) const beforeDuplicate = await state() await evaluate( `window.screenBridge.command(${JSON.stringify({ session: beforeDuplicate.session, sequence: beforeDuplicate.sequence, action: 'replay', channel: 'main' })})`, ) assert.equal((await state()).channels.main.operation, beforeDuplicate.channels.main.operation) await assert.rejects( inspect( `window.screenBridge.command({session:'${beforeDuplicate.session}',sequence:999999,action:'play',channel:'main'})`, ), ) await assert.rejects(inspect('window.screenBridge.packages()')) await assert.rejects( evaluate( `window.screenBridge.command({session:'${beforeDuplicate.session}',sequence:999999,action:'select',channel:'main',sourceId:'arbitrary-file'})`, ), ) assert.ok( await inspect( "Array.from(document.querySelectorAll('audio,video')).every(m => !m.getAttribute('src') || m.src.startsWith('screen-app://app/packages/'))", ), ) await command({ action: 'play', channel: 'music' }) const modes = [] const sceneReady = (mode) => poll(async () => { const s = await state() if (s.scene.status === 'error') throw new Error(s.scene.error) return s.scene.mode === mode && s.scene.status === 'ready' && s }, `scene ${mode} ready`) for (let index = 0; index < 8; index++) { await command({ action: 'step', index }) const s = await sceneReady(index + 1) modes.push(s.scene.mode) assert.equal(s.scene.stepIndex, index) assert.equal(s.channels.main.desired, 'stopped') assert.equal(s.channels.music.desired, 'playing') assert.equal( await inspect('document.querySelectorAll("button,input,textarea,select").length'), 0, ) if (index === 0) assert.equal(await inspect('document.querySelector("img.cover").naturalWidth'), 320) assert.equal(await inspect('document.querySelector("img.background").naturalWidth'), 320) if (index === 1 || index === 3) { const clickMedia = (label) => evaluate(`(() => { const row = document.querySelector('[data-step-index="${index}"]') const button = Array.from(row.querySelectorAll('button')).find(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)}) if (!button || button.disabled) throw new Error('Main control unavailable') button.click() })()`) await clickMedia('Запуск') await poll( async () => (await state()).channels.main.status === 'playing', 'plan media plays', ) await clickMedia('Пауза') await poll( async () => (await state()).channels.main.status === 'paused', 'plan media paused', ) await evaluate(`document.querySelector('.plan-step-current [id^="volume-main-"] [role="slider"]').focus()`) for (let attempt = 0; attempt < 100; attempt++) { const volume = (await state()).channels.main.volume if (volume === 42) break await evaluate(`document.querySelector('.plan-step-current [id^="volume-main-"] [role="slider"]') .dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(volume > 42 ? 'ArrowLeft' : 'ArrowRight')}, bubbles: true }))`) await poll(async () => (await state()).channels.main.volume !== volume, 'volume slider keyboard update') } await poll(async () => (await state()).channels.main.volume === 42, 'plan media volume') assert.equal((await state()).channels.main.desired, 'paused') assert.equal((await state()).channels.music.desired, 'playing') await clickMedia('Продолжить') await poll( async () => (await state()).channels.main.status === 'playing', 'plan media resumes', ) await clickMedia('Сначала') await poll(async () => (await state()).channels.main.position < 0.5, 'plan media restarts') await clickMedia('Стоп') await poll( async () => (await state()).channels.main.status === 'stopped', 'plan media stopped', ) } if (index === 2) assert.ok(await inspect('document.body.textContent.includes("Успехов")')) if (index === 4) assert.ok(await inspect('document.body.textContent.includes("Спасибо за игру!")')) if (index === 5) assert.ok( await inspect('document.body.textContent.includes("Результаты пока не загружены")'), ) if (index === 6) { assert.equal(s.timer.status, 'stopped') assert.equal(await inspect('document.querySelector(".timer strong").textContent'), '00:02') } if (index === 7) assert.ok(await inspect('document.body.textContent.includes("Кириллица: Ёжик и улика №7")')) } assert.equal( await evaluate( `!!document.querySelector(${JSON.stringify('#scene-text, #timer-label, #timer-seconds, [aria-label="Режим показа"]')})`, ), false, ) assert.equal(await evaluate('document.body.textContent.includes("Текст и таймер")'), false) const safeText = 'Привет\n' await command({ action: 'text', text: safeText }) await sceneReady(8) assert.ok(await inspect(`document.querySelector('.text').textContent.includes('