Every theme's full-window overlays are off while a video is open, the picture is never filtered or blended, the player's lettering is plain. --blur, as in To Do and Notes: the header (the top row of the app), dialogs, the full-screen player's note and an expanded KVM start below iOS 26's scroll-edge blur in a Home Screen app; zero on desktop and in a Safari tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
8.9 KiB
JavaScript
149 lines
8.9 KiB
JavaScript
// 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)
|
||
}
|
||
|
||
// 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)
|