Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
6.9 KiB
JavaScript
121 lines
6.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` })
|
||
|
||
// 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)
|