This commit is contained in:
2026-10-17 19:52:24 +07:00
parent a386f3d9e5
commit f5dc9c1e5b
17 changed files with 1652 additions and 59 deletions
+216 -10
View File
@@ -1,7 +1,7 @@
const assert = require('node:assert/strict')
const { app, BrowserWindow, screen } = require('electron')
const { app, BrowserWindow, screen, nativeImage } = require('electron')
const { join, resolve } = require('node:path')
const { mkdtempSync, readFileSync } = require('node:fs')
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-')))
@@ -11,7 +11,13 @@ 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)
@@ -74,17 +80,40 @@ const seeded = (async () => {
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: {
story: { introduction: { audio: 'intro.wav' }, conclusion: { video: 'ending.mp4' } },
image: 'cover.png',
story: {
background: 'background.png',
introduction: { audio: 'intro.wav' },
conclusion: { video: 'ending.mp4' },
},
},
},
},
plan: {},
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: [
@@ -102,14 +131,22 @@ const seeded = (async () => {
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)
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('.mp4') ? 'video/mp4' : 'audio/wav',
'Content-Type': url.endsWith('.png')
? 'image/png'
: url.endsWith('.mp4')
? 'video/mp4'
: 'audio/wav',
'Content-Length': String(bytes.length),
},
})
@@ -321,6 +358,172 @@ app.whenReady().then(async () => {
"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()
@@ -346,6 +549,7 @@ app.whenReady().then(async () => {
)
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({
@@ -355,6 +559,8 @@ app.whenReady().then(async () => {
files: offlinePackage.files.length,
},
display: { id: targetDisplay.id, synthetic: isSyntheticDisplay },
modes,
previewCaptures: nativeCaptures.length,
fixturePosition,
pausedAt,
samples,