Files
evening_detective_screen/scripts/smoke.cjs
T
2026-10-19 02:16:57 +07:00

861 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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') || process.argv.includes('--previews'))
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('--previews')) {
await require('./previews-smoke.cjs')({ control, output, evaluate, inspect, state, command, poll, targetDisplay })
app.exit(0)
return
}
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<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)