Files
evening_detective_screen/scripts/smoke.cjs
T
2026-10-17 19:52:24 +07:00

592 lines
24 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 } = 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 captures = []
app.on('browser-window-created', (_event, window) => {
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)
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()[0], 'control')
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("h1")')),
'control UI',
)
if (manual) {
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 !== control),
'output',
)
const inspect = (source) => output.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 evaluate(
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Пуск с начала')).click()`,
)
await poll(async () => (await state()).channels.main.status === 'playing', 'UI fixture play')
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' })
await evaluate("document.querySelector('.n-base-selection').click()")
await poll(
() =>
evaluate(`(() => {
const option = Array.from(document.querySelectorAll('.n-base-select-option')).find(el => el.textContent.includes('Offline smoke'))
if (!option) return false
option.click(); return true
})()`),
'UI package choice',
)
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)
else assert.equal(await inspect('document.querySelector("img.background").naturalWidth'), 320)
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")'))
}
const publishedText = (await state()).scene.text
await evaluate(
`(() => { const input = document.querySelector('#scene-text'); input.value = 'Черновик ещё не показан'; input.dispatchEvent(new Event('input', {bubbles:true})) })()`,
)
await delay(100)
assert.equal((await state()).scene.text, publishedText)
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 !== control), '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)
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(), 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)