Files

165 lines
9.9 KiB
JavaScript
Raw Permalink 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.
// The Files player, end to end in Chromium against a running hub. Playwright is borrowed from
// the wedding site.
//
// BASE=http://10.0.0.234:8100 USER=… PASS=… MACHINE=hub FILE=/path/to/video node scripts/e2e-player.mjs
//
// Opens FILE in the Files viewer on MACHINE ("hub" for the hub itself), plays it on Auto,
// checks the 360 view if it is one, seeks into the middle, picks the lowest quality, tries
// Original, and reads the menu's "Made on …". Changes nothing. DEVICE="Pixel 7" for a phone;
// TRACE=1 prints every piece asked for. Screenshots land in $SHOTS (default /tmp/hamlab-shots).
import { createRequire } from 'node:module'
import { mkdirSync } from 'node:fs'
const require = createRequire(import.meta.url)
const { chromium, devices } = require('/home/core/projects/wedding-website/node_modules/playwright')
const { BASE = 'http://10.0.0.234:8100', USER, PASS, MACHINE = 'hub', FILE, DEVICE, TRACE } = process.env
const SHOTS = process.env.SHOTS ?? '/tmp/hamlab-shots'
if (!USER || !PASS || !FILE) { console.error('USER, PASS and FILE are required'); process.exit(2) }
mkdirSync(SHOTS, { recursive: true })
let failed = 0
const check = (name, ok, extra = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); if (!ok) failed++ }
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] })
const ctx = await browser.newContext({ ...(DEVICE ? devices[DEVICE] : { viewport: { width: 1280, height: 860 } }), colorScheme: 'dark' })
check('signs in', (await ctx.request.post(BASE + '/api/login', { data: { username: USER, password: PASS } })).ok())
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(String(e)))
page.on('console', (m) => { if (m.type() === 'error' && !/401|404|favicon/.test(m.text())) errors.push(m.text()) })
const T0 = Date.now(), trace = []
const short = (u) => u.replace(/.*\/files\/play\/[0-9a-f]+\//, '')
page.on('request', (r) => { if (/\/files\/play\/.*\.ts$/.test(r.url())) trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} ask ${short(r.url())}`) })
page.on('requestfinished', (r) => { if (/\/files\/play\/.*\.ts$/.test(r.url())) trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} got ${short(r.url())}`) })
const mark = (m) => trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} -- ${m}`)
const dir = FILE.slice(0, FILE.lastIndexOf('/')) || '/', name = FILE.slice(FILE.lastIndexOf('/') + 1)
await page.goto(`${BASE}/#/${MACHINE}/files/${encodeURIComponent(dir)}`)
await page.locator('.file-row', { hasText: name }).first().click()
await page.locator('.vp').waitFor()
const vstate = () => page.evaluate(() => { const v = document.querySelector('.vp video'); return v ? { t: v.currentTime, paused: v.paused, w: v.videoWidth, h: v.videoHeight, rs: v.readyState, d: v.duration } : null })
async function playingPast(t, ms = 60000) {
const t0 = Date.now()
while (Date.now() - t0 < ms) {
const s = await vstate()
if (s && s.t > t && !s.paused && s.rs >= 3) return Date.now() - t0
await page.waitForTimeout(200)
}
return -1
}
async function controls() {
if (await page.locator('.vp.bare').count()) {
const b = await page.locator('.vp').boundingBox()
if (DEVICE) await page.touchscreen.tap(b.x + 40, b.y + 40)
else await page.mouse.move(b.x + b.width / 2 + Math.random() * 40, b.y + b.height / 3)
await page.waitForTimeout(300)
}
}
let took = await playingPast(1)
check('plays on Auto', took >= 0, `${((Date.now() - T0) / 1000).toFixed(1)} s from opening`)
await page.waitForTimeout(6000)
let s = await vstate()
check('Auto settles on a quality', s?.w > 0, `${s?.w}×${s?.h}; “${await page.locator('.vp-q span').textContent()}”`)
if (await page.locator('canvas.vp-sphere').count()) {
const px = await page.evaluate(() => { const c = document.querySelector('canvas.vp-sphere'); const g = c.getContext('webgl'); const d = new Uint8Array(64); g.readPixels(c.width >> 1, c.height >> 1, 4, 4, g.RGBA, g.UNSIGNED_BYTE, d); return d.reduce((a, b) => a + b, 0) })
check('the 360° view draws', px > 0)
}
await page.screenshot({ path: `${SHOTS}/h1-auto.png` })
// No theme touches playback: for every theme the stylesheet has rules for, no full-window
// overlay is drawn, the picture isn't filtered, and the player's lettering is plain.
{
const was = await page.evaluate(() => document.documentElement.getAttribute('data-theme'))
const themes = await page.evaluate(() => {
const ids = new Set()
for (const sh of document.styleSheets) { let rules; try { rules = sh.cssRules } catch { continue }
const walk = (rs) => { for (const r of rs) { if (r.cssRules) walk(r.cssRules); for (const m of (r.selectorText ?? '').matchAll(/data-theme="([^"]+)"/g)) ids.add(m[1]) } }
walk(rules) }
return [...ids]
})
const bad = []
for (const t of themes) {
const r = await page.evaluate((t) => {
document.documentElement.setAttribute('data-theme', t)
const els = [document.documentElement, document.body, document.querySelector('.app'), document.querySelector('#root')].filter(Boolean)
const overlays = els.flatMap((e) => ['::before', '::after'].map((p) => getComputedStyle(e, p))).filter((c) => c.display !== 'none' && c.content !== 'none' && c.content !== 'normal' && (c.position === 'fixed' || c.position === 'absolute')).length
const pics = [...document.querySelectorAll('.vp video, .vp canvas')].map((e) => getComputedStyle(e).filter).filter((f) => f !== 'none').length
const c = getComputedStyle(document.querySelector('.vp'))
const b = getComputedStyle(document.querySelector('.vp button'))
return { overlays, pics, plain: !/mono|courier/i.test(b.fontFamily) && c.fontWeight === '400' && c.letterSpacing === 'normal' && c.textShadow === 'none' }
}, t)
if (r.overlays || r.pics || !r.plain) bad.push(`${t}: ${JSON.stringify(r)}`)
}
check(`no theme touches the video (${themes.length} themes with their own rules)`, themes.length > 0 && bad.length === 0, bad.join(' / ') || themes.join(', '))
await page.evaluate((t) => t ? document.documentElement.setAttribute('data-theme', t) : document.documentElement.removeAttribute('data-theme'), was)
}
// Volume: the speaker mutes and unmutes, the slider sets the level, and it is remembered.
{
await controls()
const vs = () => page.evaluate(() => { const v = document.querySelector('.vp video'); return { muted: v.muted, volume: Math.round(v.volume * 100) / 100 } })
await page.getByRole('button', { name: 'Mute' }).click()
const a = await vs()
await page.getByRole('button', { name: 'Unmute' }).click()
const b = await vs()
let c = b
if (await page.getByLabel('Volume').isVisible()) { await page.getByLabel('Volume').fill('0.3'); c = await vs() }
const kept = await page.evaluate(() => Object.entries(localStorage).find(([k]) => /volume/.test(k))?.[1] ?? '')
check('volume: mute, unmute, level, remembered', a.muted && !b.muted && (c.volume === 0.3 || !(await page.getByLabel('Volume').isVisible())) && /level/.test(kept),
`${JSON.stringify(a)} → ${JSON.stringify(b)} → ${JSON.stringify(c)}, kept ${kept}`)
if (await page.getByLabel('Volume').isVisible()) await page.getByLabel('Volume').fill('1')
}
// The 5-second buttons: back and on, the ball with them.
{
await controls()
const t0 = (await vstate()).t
await page.getByRole('button', { name: 'On 5 seconds' }).click()
const t1 = (await vstate()).t
await page.getByRole('button', { name: 'Back 5 seconds' }).click()
await page.getByRole('button', { name: 'Back 5 seconds' }).click()
const t2 = (await vstate()).t
const ball = await page.evaluate(() => Number(document.querySelector('.vp input[type=range]').value))
check('+5 and −5 buttons', t1 - t0 > 4 && t1 - t0 < 6.5 && t1 - t2 > 9 && t1 - t2 < 11 && Math.abs(ball - t2) < 1, `${t0.toFixed(1)} → ${t1.toFixed(1)} → ${t2.toFixed(1)}, ball ${ball.toFixed(1)}`)
}
const mid = Math.floor(s.d / 2)
mark('seek')
await page.evaluate((m) => { document.querySelector('.vp video').currentTime = m }, mid)
took = await playingPast(mid + 0.5)
check('a seek into the middle plays again', took >= 0, `${(took / 1000).toFixed(1)} s`)
await controls()
await page.locator('.vp-q').click()
await page.waitForTimeout(2500) // the menu asks the machine what it is doing
const items = page.locator('.vp-menu button')
const n = await items.count()
check('the menu lists Auto, Original and the qualities', n >= 3, (await items.allTextContents()).join(' | '))
const foot = await page.locator('.vp-menu-f').textContent()
check('it says where it is made', /Made on \S+/.test(foot), foot)
await page.screenshot({ path: `${SHOTS}/h2-menu.png` })
const lowLabel = await items.nth(n - 1).locator('span').nth(1).textContent()
mark('pick ' + lowLabel)
const before = (await vstate()).t
const t0 = Date.now()
await items.nth(n - 1).click()
let ok = false
for (let i = 0; i < 200 && !ok; i++) { await page.waitForTimeout(200); s = await vstate(); ok = s.t > before + 1 && !s.paused && (await page.locator('.vp-q span').textContent()) === lowLabel }
check(`picking ${lowLabel} switches to it`, ok, `${s.w}×${s.h} after ${((Date.now() - t0) / 1000).toFixed(1)} s`)
await controls()
await page.locator('.vp-q').click()
await page.locator('.vp-menu button', { hasText: 'Original' }).click()
await page.waitForTimeout(4000)
const label = await page.locator('.vp-q span').textContent()
const note = await page.locator('.vp-note').count() ? await page.locator('.vp-note').textContent() : ''
check('Original plays, or falls back to Auto and says so', label === 'Original' || (/^Auto/.test(label) && /can’t play/.test(note)), `${label}${note ? ' — ' + note : ''}`)
check('still playing', (await playingPast((await vstate()).t + 0.5)) >= 0)
await page.screenshot({ path: `${SHOTS}/h3-after-original.png` })
check('no page errors', errors.length === 0, errors.slice(0, 3).join(' / '))
if (TRACE) console.log(trace.join('\n'))
await browser.close()
console.log(failed ? `${failed} failed` : 'all ok')
process.exit(failed ? 1 : 0)