724 lines
31 KiB
JavaScript
724 lines
31 KiB
JavaScript
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('--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))
|
||
}
|
||
assert.equal(await evaluate('typeof window.require'), 'undefined')
|
||
assert.equal((await evaluate('window.screenBridge.packages()')).length, 1)
|
||
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)
|
||
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<script>window.__unsafe = true</script>'
|
||
await command({ action: 'text', text: safeText })
|
||
await sceneReady(8)
|
||
assert.ok(await inspect(`document.querySelector('.text').textContent.includes('<script>')`))
|
||
assert.equal(await inspect('typeof window.__unsafe'), 'undefined')
|
||
assert.equal(await inspect('document.fonts.check("16px system-ui", "Кириллица Ёжик №7")'), true)
|
||
await command({ action: 'timer-set', label: 'Перерыв', durationSeconds: 2 })
|
||
await sceneReady(7)
|
||
await command({ action: 'timer-start' })
|
||
await delay(300)
|
||
await command({ action: 'timer-pause' })
|
||
const remaining = (await state()).timer.remainingMs
|
||
await delay(300)
|
||
assert.equal((await state()).timer.remainingMs, remaining)
|
||
await command({ action: 'timer-resume' })
|
||
await command({ action: 'mode', mode: 1 })
|
||
await sceneReady(1)
|
||
await poll(
|
||
async () => (await state()).timer.status === 'finished',
|
||
'timer finishes while hidden',
|
||
)
|
||
await command({ action: 'mode', mode: 7 })
|
||
await sceneReady(7)
|
||
assert.equal(await inspect('document.querySelector(".timer strong").textContent'), '00:00')
|
||
await command({ action: 'timer-reset' })
|
||
await poll(
|
||
async () =>
|
||
(await inspect('document.querySelector(".timer strong").textContent')) === '00:02',
|
||
'timer reset DOM',
|
||
)
|
||
control.focus()
|
||
await poll(
|
||
() =>
|
||
evaluate(
|
||
`(() => { const image = document.querySelector('[data-testid=preview]'); return !!image && image.complete && image.naturalWidth > 0 && image.naturalWidth <= 960 && image.src.startsWith('data:image/png') })()`,
|
||
),
|
||
'native preview image without network',
|
||
)
|
||
assert.equal(await evaluate('document.querySelectorAll("audio,video").length'), 0)
|
||
await assert.rejects(inspect('window.screenBridge.preview()'))
|
||
const sceneBefore = (await state()).scene
|
||
await evaluate(
|
||
`(async () => { const s = await window.screenBridge.state(); window.screenBridge.reportScene({session:s.session, revision:s.scene.revision, status:'error',error:'forged'}) })()`,
|
||
)
|
||
await delay(50)
|
||
assert.equal((await state()).scene.status, sceneBefore.status)
|
||
const backgroundId = offlinePackage.package.media.background
|
||
const backgroundPath = join(
|
||
app.getPath('userData'),
|
||
'packages',
|
||
offlinePackage.key,
|
||
`v-${offlinePackage.version}`,
|
||
'assets',
|
||
backgroundId,
|
||
)
|
||
const originalImage = readFileSync(backgroundPath)
|
||
writeFileSync(backgroundPath, Buffer.alloc(originalImage.length))
|
||
await command({ action: 'text', text: 'Проверка изображения' })
|
||
await poll(
|
||
async () => (await state()).scene.status === 'error',
|
||
'broken image error on control',
|
||
)
|
||
assert.equal(await inspect('document.body.textContent.includes("Не удалось")'), false)
|
||
assert.equal((await state()).channels.music.desired, 'playing')
|
||
writeFileSync(backgroundPath, originalImage)
|
||
await command({ action: 'mode', mode: 8 })
|
||
await sceneReady(8)
|
||
await command({ action: 'stop', channel: 'music' })
|
||
const nativeCaptures = captures.filter((capture) => capture.id === output.id)
|
||
assert.ok(nativeCaptures.length > 0)
|
||
for (let index = 1; index < nativeCaptures.length; index++)
|
||
assert.ok(
|
||
nativeCaptures[index].time - nativeCaptures[index - 1].time >= 490,
|
||
'preview limited to 2 FPS',
|
||
)
|
||
// Фикстура проверяет таблицу и прокрутку в renderer, не создавая результаты в main.
|
||
await command({ action: 'mode', mode: 6 })
|
||
await sceneReady(6)
|
||
const realState = await state()
|
||
const resultFixture = {
|
||
...realState,
|
||
revision: realState.revision + 1000000,
|
||
scene: { ...realState.scene, revision: realState.scene.revision + 1 },
|
||
results: {
|
||
packageKey: offlinePackage.key,
|
||
capturedAt: '2026-10-17T10:00:00.000Z',
|
||
questions: [
|
||
{ code: 'К1', text: 'Первый вопрос' },
|
||
{ code: 'К2', text: 'Второй вопрос' },
|
||
],
|
||
teams: Array.from({ length: 50 }, (_, i) => ({
|
||
id: `fixture-${i}`,
|
||
name: `Детективы №${i + 1}`,
|
||
place: i + 1,
|
||
totalScore: 10,
|
||
actionsCount: 3,
|
||
answers: [{ questionCode: 'К1', score: 10 }],
|
||
})),
|
||
},
|
||
}
|
||
output.webContents.send('state', resultFixture)
|
||
await poll(
|
||
() => inspect('document.querySelectorAll("tbody tr").length === 50'),
|
||
'results table fixture',
|
||
)
|
||
assert.equal(
|
||
await inspect('document.querySelector("tbody tr td:nth-child(4)").textContent'),
|
||
'0',
|
||
)
|
||
const scroll = await inspect('document.querySelector(".results-scroll").scrollTop')
|
||
assert.ok(scroll > 0)
|
||
await delay(250)
|
||
const afterScroll = await inspect('document.querySelector(".results-scroll").scrollTop')
|
||
assert.ok(afterScroll < scroll)
|
||
output.webContents.send('state', {
|
||
...resultFixture,
|
||
revision: resultFixture.revision + 1,
|
||
results: structuredClone(resultFixture.results),
|
||
})
|
||
await delay(250)
|
||
assert.ok(
|
||
await inspect(`document.querySelector('.results-scroll').scrollTop < ${afterScroll}`),
|
||
'media state updates do not restart result scrolling',
|
||
)
|
||
const samples = (await state()).samples
|
||
const oldSession = (await state()).session
|
||
output.close()
|
||
await evaluate(`window.screenBridge.show(${targetDisplay.id})`)
|
||
output = await poll(() => BrowserWindow.getAllWindows().find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1'), 'reopened')
|
||
await poll(async () => {
|
||
const s = await state()
|
||
return (
|
||
s.session !== oldSession &&
|
||
s.channels.main.status === 'stopped' &&
|
||
s.channels.music.status === 'stopped'
|
||
)
|
||
}, 'reopened prepared, stopped')
|
||
await checkDisplay(output)
|
||
assert.equal(
|
||
await inspect(
|
||
"Array.from(document.querySelectorAll('audio,video')).filter(m => !m.paused).length",
|
||
),
|
||
0,
|
||
)
|
||
await evaluate(
|
||
`window.screenBridge.command(${JSON.stringify({ session: oldSession, sequence: 9999999, action: 'play', channel: 'main' })})`,
|
||
)
|
||
assert.equal((await state()).channels.main.desired, 'stopped')
|
||
assert.equal(await evaluate('typeof window.screenBridge.lan'), 'undefined')
|
||
assert.equal((await state()).results, null)
|
||
await require('./online-smoke.cjs')({
|
||
evaluate,
|
||
inspect,
|
||
state,
|
||
command,
|
||
poll,
|
||
displayId: targetDisplay.id,
|
||
root,
|
||
})
|
||
await require('./venue-smoke.cjs')({
|
||
control,
|
||
evaluate,
|
||
state,
|
||
command,
|
||
poll,
|
||
targetDisplay,
|
||
offlinePackage,
|
||
powerBlocks,
|
||
})
|
||
console.log(
|
||
'SCREEN_SMOKE_PASS',
|
||
JSON.stringify({
|
||
range,
|
||
offlinePackage: {
|
||
name: offlinePackage.package.game.name,
|
||
files: offlinePackage.files.length,
|
||
},
|
||
display: { id: targetDisplay.id, synthetic: isSyntheticDisplay },
|
||
modes,
|
||
previewCaptures: nativeCaptures.length,
|
||
fixturePosition,
|
||
pausedAt,
|
||
samples,
|
||
electron: process.versions.electron,
|
||
}),
|
||
)
|
||
app.exit(0)
|
||
} catch (error) {
|
||
for (const window of BrowserWindow.getAllWindows()) {
|
||
try {
|
||
console.error(
|
||
'SMOKE_DIAGNOSTIC',
|
||
window.webContents.getURL(),
|
||
await window.webContents.executeJavaScript(
|
||
"(async () => ({ state: await window.screenBridge.state(), host: await window.screenBridge.host().catch(() => null), media: Array.from(document.querySelectorAll('audio,video')).map(m => ({channel:m.dataset.channel,src:m.src,paused:m.paused,ready:m.readyState,time:m.currentTime,duration:m.duration,error:m.error?.message})) }))()",
|
||
),
|
||
)
|
||
} catch {}
|
||
}
|
||
console.error('SCREEN_SMOKE_FAIL', error)
|
||
app.exit(1)
|
||
}
|
||
})
|
||
if (!manual)
|
||
setTimeout(() => {
|
||
console.error('Global timeout')
|
||
app.exit(1)
|
||
}, 90000)
|