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

386 lines
15 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 } = require('electron')
const { join, resolve } = require('node:path')
const { mkdtempSync, readFileSync } = 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()
app.on('browser-window-created', (_event, window) => {
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 pkg = buildScreenPackage('https://offline.invalid', 7, {
game: {
game: {
ID: 7,
name: 'Offline smoke',
scenario: {
story: { introduction: { audio: 'intro.wav' }, conclusion: { video: 'ending.mp4' } },
},
},
},
plan: {},
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('intro.wav')
? audio
: url.endsWith('.mp4')
? video
: musicWav(url.endsWith('two.wav') ? 660 : 330)
return new Response(bytes, {
headers: {
'Content-Type': 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/'))",
),
)
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')
console.log(
'SCREEN_SMOKE_PASS',
JSON.stringify({
range,
offlinePackage: {
name: offlinePackage.package.game.name,
files: offlinePackage.files.length,
},
display: { id: targetDisplay.id, synthetic: isSyntheticDisplay },
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)