#!/usr/bin/env node // Device autosave checks against a real, isolated Bun server and a local H.264 clip. import assert from 'node:assert/strict'; import { spawn, execFileSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, symlinkSync, rmSync, statSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { createServer } from 'node:net'; import { createRequire } from 'node:module'; import { chromium, webkit } from 'playwright'; const root = path.resolve(import.meta.dirname, '..'); const require = createRequire(import.meta.url); const { createClient } = require(path.join(root, 'server/node_modules/@libsql/client')); const args = process.argv.slice(2); function option(name, fallback) { const i = args.indexOf(name); return i < 0 ? fallback : args[i + 1]; } const target = option('--browser', 'all'); assert.ok(['all', 'chromium', 'webkit'].includes(target)); mkdirSync(path.join(root, 'perf/.tmp'), { recursive: true }); const scratch = mkdtempSync(path.join(root, 'perf/.tmp/autosave-')); const revision = option('--frontend-commit', null); let tree = path.resolve(option('--frontend', path.join(root, 'frontend'))); if (revision) { const archive = path.join(scratch, 'frontend.tar'); writeFileSync(archive, execFileSync('git', ['archive', revision, 'frontend'], { cwd: root, maxBuffer: 30 * 1024 * 1024 })); execFileSync('tar', ['-xf', archive, '-C', scratch]); tree = path.join(scratch, 'frontend'); } const portProbe = createServer(); await new Promise(r => portProbe.listen(0, '127.0.0.1', r)); const port = portProbe.address().port; await new Promise(r => portProbe.close(r)); const origin = `http://127.0.0.1:${port}`; const clip = { id: 'benchmedia01', title: 'Autosave local clip', channel: 'Regression', duration: 3, thumbnail: '' }; const media = path.join(scratch, 'data/media'); mkdirSync(media, { recursive: true }); symlinkSync(tree, path.join(scratch, 'public')); const mp4 = path.join(media, clip.id + '.1.mp4'); const dbPath = path.join(scratch, 'data/test.db'); const child = spawn('bun', [path.join(root, 'server/server.js')], { cwd: scratch, env: { ...process.env, PORT: String(port), DB_PATH: dbPath, MEDIA_DIR: media, UPLOAD_DIR: path.join(scratch, 'uploads'), DATA_DIR: path.join(scratch, 'data'), ASSET_HASHING: option('--asset-hashing', '1'), ASSET_SYNC: option('--asset-sync', '1'), BUILD_TAG: '' }, stdio: ['ignore', 'pipe', 'pipe'] }); let serverLog = ''; for (const stream of [child.stdout, child.stderr]) stream.on('data', c => { serverLog = (serverLog + c).slice(-5000); }); const results = { revision: revision || tree, browsers: {}, failures: [], unsupported: [] }; const scenarios = ['open-default', 'open-save-first', 'open-audio-only', 'open-prefer-stream', 'playlist-add', 'playlist-add-setting-off', 'playlist-existing-add', 'playlist-boot', 'playlist-boot-off', 'pinned-boot', 'settings-enable', 'settings-save-first']; try { for (let i = 0; ; i++) { try { if ((await fetch(origin + '/api/version')).ok) break; } catch {} if (i === 100 || child.exitCode !== null) throw Error('Bun server failed: ' + serverLog); await new Promise(r => setTimeout(r, 100)); } results.buildTag = (await (await fetch(origin + '/api/version')).json()).buildTag; execFileSync('ffmpeg', ['-y', '-f', 'lavfi', '-i', 'color=c=blue:s=160x90:r=10:d=3', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', '-shortest', mp4], { stdio: 'ignore' }); execFileSync('ffmpeg', ['-y', '-i', mp4, '-vn', '-c:a', 'copy', path.join(media, clip.id + '.1.m4a')], { stdio: 'ignore' }); const db = createClient({ url: 'file:' + dbPath }); await db.execute({ sql: 'INSERT OR REPLACE INTO media_cache (video_id,status,gen,size,height,vcodec,acodec,duration,meta,last_access) VALUES (?,?,?,?,?,?,?,?,?,?)', args: [clip.id, 'ready', 1, statSync(mp4).size, 90, 'h264', 'aac', 3, JSON.stringify(clip), Date.now()] }); db.close(); for (const [name, type] of Object.entries({ chromium, webkit })) { if (target !== 'all' && target !== name) continue; const browser = await type.launch({ headless: true }); results.browsers[name] = []; try { for (const scenario of scenarios.filter(s => !option('--scenarios', null) || option('--scenarios').split(',').includes(s))) { const context = await browser.newContext({ viewport: { width: 414, height: 896 }, serviceWorkers: 'allow' }); const page = await context.newPage(), errors = []; page.on('pageerror', e => errors.push(e.message)); const autoPreload = !['pinned-boot', 'playlist-boot-off', 'playlist-add-setting-off', 'settings-enable'].includes(scenario); const settings = { layout: 'f7-swipe', autoPreload, saveBeforePlay: ['open-save-first', 'open-audio-only', 'open-prefer-stream'].includes(scenario), audioOnly: scenario === 'open-audio-only' }; const hasPlaylist = scenario.endsWith('boot') || ['playlist-boot-off', 'settings-enable', 'playlist-existing-add'].includes(scenario); const playlists = hasPlaylist ? [{ id: 'test', name: 'Autosave playlist', videos: scenario === 'playlist-existing-add' ? [] : [clip] }] : []; await context.addInitScript(({ settings, playlists, clip, scenario }) => localStorage.setItem('_ytpdata', JSON.stringify({ settings, playlists, history: [clip], pinnedPlaylists: scenario === 'pinned-boot' ? ['test'] : [] })), { settings, playlists, clip, scenario }); // No public service/YouTube requests; the local clip uses real prepare/range endpoints. await context.route('**/*', r => r.request().url().startsWith(origin) ? r.continue() : r.abort()); await context.route('**/api/streams/warm**', r => r.fulfill({ json: { ok: true } })); try { await page.goto(origin, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => typeof data !== 'undefined' && window.SettingsSections?.get('downloads-storage')); const supported = await page.evaluate(() => OPFS.isSupported()); if (!supported && !results.unsupported.includes(name)) results.unsupported.push(name); // Count invocations in browsers without native OPFS; never treat this as stored bytes. await page.evaluate(() => { window.__saveCalls = 0; const original = API.cacheDownload; API.cacheDownload = (...a) => { window.__saveCalls++; return original(...a); }; }); if (scenario.startsWith('open')) await page.evaluate(({ clip, scenario }) => { Player.loadVideo(clip, { preferStream: scenario === 'open-prefer-stream' }); }, { clip, scenario }); if (scenario.startsWith('playlist-add')) { await page.evaluate(clip => newPlaylist(clip), clip); await page.locator('#modal input').fill('Autosave playlist'); await page.getByRole('button', { name: 'Create', exact: true }).evaluate(b => b.click()); } if (scenario === 'playlist-existing-add') { await page.evaluate(clip => openCardMenu(clip), clip); await page.getByRole('button', { name: '+ Autosave playlist', exact: true }).evaluate(b => b.click()); } if (scenario.startsWith('settings-')) { await page.evaluate(() => { view = { type: 'settings' }; renderList(); }); const id = scenario === 'settings-enable' ? 'setAutoPreload' : 'setSaveBeforePlay'; await page.locator('#' + id).evaluate(e => { e.checked = true; e.dispatchEvent(new Event('change', { bubbles: true })); }); const key = scenario === 'settings-enable' ? 'autoPreload' : 'saveBeforePlay'; await page.waitForFunction(key => JSON.parse(localStorage.getItem('_ytpdata')).settings[key] === true, key); if (scenario === 'settings-save-first') await page.evaluate(clip => { Player.loadVideo(clip); }, clip); } const expected = !['open-default', 'open-audio-only', 'open-prefer-stream', 'playlist-boot-off'].includes(scenario); if (supported && expected) await page.waitForFunction(id => cachedIds.has(id), clip.id, { timeout: 15000 }); else await page.waitForTimeout(1000); const state = await page.evaluate(async id => ({ cached: cachedIds.has(id), files: OPFS.isSupported() ? await OPFS.listVideos() : [], queue: SaveQueue.pending(), calls: window.__saveCalls }), clip.id); assert.equal(state.cached, expected && supported, JSON.stringify(state)); assert.equal(state.files.some(f => f.id === clip.id && f.size > 0), expected && supported, JSON.stringify(state)); if (!supported && expected && !['open-save-first', 'settings-save-first', 'playlist-boot', 'pinned-boot'].includes(scenario)) assert.ok(state.calls > 0, 'save action was not invoked'); if (supported && expected) { assert.equal(state.files.find(f => f.id === clip.id).size, statSync(mp4).size, 'stored file is incomplete'); await page.evaluate(() => { view = { type: 'saved' }; renderList(); }); await page.waitForFunction(() => document.getElementById('cards').textContent.includes('Autosave local clip')); await page.evaluate(() => { view = { type: 'downloads' }; renderList(); }); await page.waitForFunction(() => document.getElementById('listTitle').textContent === 'Downloads'); } const result = { scenario, supported, saved: state.cached, bytes: state.files.find(f => f.id === clip.id)?.size || 0, calls: state.calls, errors }; results.browsers[name].push(result); console.log(`${name} ${scenario}: PASS ${supported ? 'saved=' + state.cached : 'UI/save guard only; OPFS unavailable'}`); } catch (e) { results.failures.push({ browser: name, scenario, error: e.message, errors }); console.log(`${name} ${scenario}: FAIL ${e.message.split('\n')[0]}`); } finally { await context.close(); } } } finally { await browser.close(); } } } finally { if (child.exitCode === null) { child.kill('SIGTERM'); await new Promise(r => child.once('exit', r)); } rmSync(scratch, { recursive: true, force: true }); const out = option('--out', null); if (out) { mkdirSync(path.dirname(path.resolve(out)), { recursive: true }); writeFileSync(out, JSON.stringify(results, null, 2) + '\n'); } } if (results.failures.length) process.exitCode = 1; else if (results.unsupported.length && !args.includes('--allow-unsupported-webkit')) { console.error('Native OPFS not verified in: ' + results.unsupported.join(', ')); process.exitCode = 2; }