// 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` }) // 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)