From b59fc7264a7002ebabe25542291d3b0637949a64 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 9 Oct 2026 09:16:58 +0800 Subject: [PATCH 1/2] Verify device autosave behavior across frontend revisions --- frontend/autosave.test.js | 65 +++++++++ perf/README.md | 68 +++++++++ perf/autosave.mjs | 128 +++++++++++++++++ perf/results/autosave-b77938a.json | 208 ++++++++++++++++++++++++++++ perf/results/autosave-current.json | 110 +++++++++++++++ perf/results/autosave-main.json | 208 ++++++++++++++++++++++++++++ perf/results/autosave-phase1.json | 110 +++++++++++++++ perf/results/autosave-phase2.json | 110 +++++++++++++++ perf/results/autosave-phase3.json | 60 ++++++++ perf/results/autosave-phase4.json | 60 ++++++++ perf/results/autosave-phase5.json | 60 ++++++++ perf/results/autosave-rollback.json | 62 +++++++++ 12 files changed, 1249 insertions(+) create mode 100644 frontend/autosave.test.js create mode 100644 perf/autosave.mjs create mode 100644 perf/results/autosave-b77938a.json create mode 100644 perf/results/autosave-current.json create mode 100644 perf/results/autosave-main.json create mode 100644 perf/results/autosave-phase1.json create mode 100644 perf/results/autosave-phase2.json create mode 100644 perf/results/autosave-phase3.json create mode 100644 perf/results/autosave-phase4.json create mode 100644 perf/results/autosave-phase5.json create mode 100644 perf/results/autosave-rollback.json diff --git a/frontend/autosave.test.js b/frontend/autosave.test.js new file mode 100644 index 0000000..812c1f6 --- /dev/null +++ b/frontend/autosave.test.js @@ -0,0 +1,65 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +// Exercise the actual app functions without DOM/player/network dependencies. +const source = fs.readFileSync(require('node:path').join(__dirname, 'app.js'), 'utf8'); +function harness({ autoPreload = true, cached = [], result = { ok: true, cached: true }, error } = {}) { + const calls = [], jobs = new Map(); + const video = { id: 'benchmedia01', title: 'Local clip' }; + const context = vm.createContext({ + data: { settings: { autoPreload }, playlists: [{ id: 'pl', videos: [video] }], pinnedPlaylists: [] }, + cachedIds: new Set(cached), downloading: new Set(), downloadMeta: new Map(), saveControllers: new Map(), + view: { type: 'home' }, current: null, window: {}, AbortController, + slim: v => v, markCardCacheState() {}, renderSidebar() {}, updateDownloadBadge() {}, toast() {}, + SaveSlots: { acquire: async () => {}, release() {} }, + SaveQueue: { add: v => jobs.set(v.id, { status: 'queued' }), remove: id => jobs.delete(id), progress() {}, state: (id, status, message) => jobs.set(id, { status, message }) }, + API: { cacheDownload: async id => { calls.push(id); if (error) throw error; return result; } }, + recordDeviceFile() {}, warmThumb() {}, thumbUrlFor: () => '', cacheMutations: 0, + }); + const preload = source.slice(source.indexOf('async function preload(video,'), source.indexOf('// A device copy that failed to play.')); + const playlists = source.slice(source.indexOf('function preloadPlaylist(pl)'), source.indexOf('// Update a single card\'s saved badge')); + vm.runInContext(preload + playlists, context); + return { context, calls, jobs, video }; +} + +test('auto preload tops up playlists, while pinned playlists save with the setting off', async () => { + const h = harness({ autoPreload: false }); + h.context.preloadPlaylist(h.context.data.playlists[0]); + await new Promise(r => setImmediate(r)); + assert.deepEqual(h.calls, []); + h.context.data.pinnedPlaylists = ['pl']; + h.context.preloadPinnedPlaylists(); + await new Promise(r => setImmediate(r)); + assert.deepEqual(h.calls, [h.video.id]); + assert.ok(h.context.cachedIds.has(h.video.id)); + assert.equal(h.jobs.size, 0); + assert.equal(h.context.downloading.size, 0); + const enabled = harness(); + enabled.context.preloadPlaylist(enabled.context.data.playlists[0]); + await new Promise(r => setImmediate(r)); + assert.deepEqual(enabled.calls, [enabled.video.id]); +}); + +test('a repeated save shares the in-flight operation and does not download an existing copy', async () => { + const h = harness(); + await Promise.all([h.context.preload(h.video), h.context.preload(h.video)]); + await h.context.preload(h.video); + assert.deepEqual(h.calls, [h.video.id]); + assert.ok(h.context.cachedIds.has(h.video.id)); + assert.equal(h.context.saveControllers.size, 0); +}); + +test('failed and interrupted saves do not acquire a Saved badge and can be attempted again', async () => { + for (const options of [{ result: { ok: false, error: 'server failed' } }, { error: new Error('storage quota') }, { result: { ok: false, paused: true } }]) { + const h = harness(options); + await h.context.preload(h.video, { quiet: true }); + assert.equal(h.context.cachedIds.has(h.video.id), false); + assert.equal(h.context.downloading.size, 0); + assert.equal(h.context.saveControllers.size, 0); + assert.ok(h.jobs.has(h.video.id)); + await h.context.preload(h.video, { quiet: true }); + assert.equal(h.calls.length, 2); + } +}); diff --git a/perf/README.md b/perf/README.md index 0b64796..cd39f8e 100644 --- a/perf/README.md +++ b/perf/README.md @@ -335,3 +335,71 @@ No product behavior changes are involved in these measurement corrections. Keep browser/build/test processes idle during full timing measurements. WebKit's zero long-task field means unavailable, not zero work. Autoplay-blocked/media-ready null results do not establish playback performance or iPhone audio continuity. + +## Device autosave investigation (2026-10-09) + +`autosave.mjs` starts an isolated real Bun server inside this worktree, seeds its +media-cache DB with a three-second H.264/AAC clip, and uses 414×896 Playwright +contexts with the Framework7 Swipe layout. It checks OPFS bytes, `cachedIds`, +Saved and Downloads views, and both Settings handlers. Public network requests +are blocked. No production access or YouTube download is needed. + +```bash +bash perf/make-shell-fixture.sh b77938a 1175f1a1d2c1 +node perf/autosave.mjs --frontend perf/fixtures/shell-1175f1a1d2c1 \ + --asset-hashing 0 --asset-sync 0 --allow-unsupported-webkit \ + --out perf/results/autosave-b77938a.json +node perf/autosave.mjs --frontend-commit main --allow-unsupported-webkit \ + --out perf/results/autosave-main.json +node perf/autosave.mjs --browser chromium --out perf/results/autosave-current.json +node --test frontend/autosave.test.js +``` + +Options: `--frontend ` or `--frontend-commit `; +`--browser chromium|webkit|all`; `--scenarios `; +`--asset-hashing 0|1`, `--asset-sync 0|1`; `--out `. +A failed assertion exits 1. Missing native OPFS exits 2 unless explicitly +allowed with `--allow-unsupported-webkit`. On this Linux Playwright WebKit build, +`navigator.storage` is absent, including in persistent contexts; enabling its +MiniBrowser storage feature flags also did not expose it. WebKit therefore +checks real UI/settings/save guards and records `supported:false`; those results +**do not establish a successful WebKit OPFS save**. No storage shim is installed. +Run without the allowance on a Safari-capable environment to require native OPFS. + +Observed contracts in both b77938a and current code: + +| Action | Device save | +| --- | --- | +| Open a video with default settings | No | +| Open with `saveBeforePlay:true`, video mode | Yes | +| Open audio-only, or `preferStream:true` | No | +| Create/add to a playlist, even with `autoPreload:false` | Yes | +| Launch with playlist videos and `autoPreload:true` | Yes | +| Launch with `autoPreload:false`, unpinned playlist | No | +| Launch with a pinned playlist, even with auto-preload off | Yes | +| Enable either save setting through Settings | Saves as above; persisted | + +Chromium: all 12 cases pass for old/current builds and Phase 1/2 tips. +Phase 3/4/5 tips and both rollback flags pass the six positive cases +(`open-save-first,playlist-add,playlist-boot,pinned-boot,settings-enable,settings-save-first`). +JSON evidence is in `perf/results/autosave-*.json`. No old=saves/new=doesn't +transition was found, so there is no defensible breaking commit or product fix. +The unit tests cover playlist/pinning policy, duplicate saves, and failed or +paused saves remaining retryable without being marked Saved. + +Neighbour audit: Downloads, Saved, OPFS, download controls and error helpers +remain eager core assets. Export awaits `feature:export`; Share/External await +`feature:share-external`; worker creation is synchronous and receives build-local +hash/resume URLs. DirectMedia's unloaded `hasSource()` facade returns false, so +an early paired-source lookup can choose the server instead; this does not +prevent the server save. P2P settings counts queried before its delayed lazy +startup can initially show no registration. These remain follow-up candidates, +not demonstrated causes of missing OPFS saves. The scout's proposed Data Saver +cause was rejected: its guards affect static warming and stream pre-resolution, +not `preload()` or `saveBeforePlay`. + +Still required on the owner's installed iPhone PWA: record the running build, +check Save before playing / Audio-only / Auto-save settings, open a fresh video, +add it to a playlist, and inspect Downloads failure details and Saved after a +relaunch/offline playback. Verify available storage and whether iOS evicted +previous copies. A failing iPhone sequence is needed to continue the bisect. diff --git a/perf/autosave.mjs b/perf/autosave.mjs new file mode 100644 index 0000000..a591f1a --- /dev/null +++ b/perf/autosave.mjs @@ -0,0 +1,128 @@ +#!/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; } diff --git a/perf/results/autosave-b77938a.json b/perf/results/autosave-b77938a.json new file mode 100644 index 0000000..713e90c --- /dev/null +++ b/perf/results/autosave-b77938a.json @@ -0,0 +1,208 @@ +{ + "revision": "/home/josh/development/personal/ytplayer-codex/wt-autosave/perf/fixtures/shell-1175f1a1d2c1", + "browsers": { + "chromium": [ + { + "scenario": "open-default", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "open-audio-only", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-prefer-stream", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-add-setting-off", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-existing-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-boot-off", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "pinned-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "settings-enable", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "settings-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + } + ], + "webkit": [ + { + "scenario": "open-default", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-save-first", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-audio-only", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-prefer-stream", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-add", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-add-setting-off", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-existing-add", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-boot", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-boot-off", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "pinned-boot", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "settings-enable", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 1, + "errors": [] + }, + { + "scenario": "settings-save-first", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + } + ] + }, + "failures": [], + "unsupported": [ + "webkit" + ], + "buildTag": "1175f1a1d2c1" +} diff --git a/perf/results/autosave-current.json b/perf/results/autosave-current.json new file mode 100644 index 0000000..ddee94f --- /dev/null +++ b/perf/results/autosave-current.json @@ -0,0 +1,110 @@ +{ + "revision": "/home/josh/development/personal/ytplayer-codex/wt-autosave/frontend", + "browsers": { + "chromium": [ + { + "scenario": "open-default", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "open-audio-only", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-prefer-stream", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-add-setting-off", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-existing-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-boot-off", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "pinned-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "settings-enable", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + }, + { + "scenario": "settings-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + } + ] + }, + "failures": [], + "unsupported": [], + "buildTag": "e93dbca4dc5a" +} diff --git a/perf/results/autosave-main.json b/perf/results/autosave-main.json new file mode 100644 index 0000000..b29700a --- /dev/null +++ b/perf/results/autosave-main.json @@ -0,0 +1,208 @@ +{ + "revision": "main", + "browsers": { + "chromium": [ + { + "scenario": "open-default", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "open-audio-only", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-prefer-stream", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-add-setting-off", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-existing-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-boot-off", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "pinned-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "settings-enable", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "settings-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + } + ], + "webkit": [ + { + "scenario": "open-default", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-save-first", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-audio-only", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-prefer-stream", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-add", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-add-setting-off", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-existing-add", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-boot", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-boot-off", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "pinned-boot", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "settings-enable", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 1, + "errors": [] + }, + { + "scenario": "settings-save-first", + "supported": false, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + } + ] + }, + "failures": [], + "unsupported": [ + "webkit" + ], + "buildTag": "c3dbdd7039db" +} diff --git a/perf/results/autosave-phase1.json b/perf/results/autosave-phase1.json new file mode 100644 index 0000000..14ee788 --- /dev/null +++ b/perf/results/autosave-phase1.json @@ -0,0 +1,110 @@ +{ + "revision": "codex/phase1", + "browsers": { + "chromium": [ + { + "scenario": "open-default", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "open-audio-only", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-prefer-stream", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-add-setting-off", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-existing-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-boot-off", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "pinned-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "settings-enable", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + }, + { + "scenario": "settings-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + } + ] + }, + "failures": [], + "unsupported": [], + "buildTag": "288787b9f7db" +} diff --git a/perf/results/autosave-phase2.json b/perf/results/autosave-phase2.json new file mode 100644 index 0000000..f4c38c7 --- /dev/null +++ b/perf/results/autosave-phase2.json @@ -0,0 +1,110 @@ +{ + "revision": "codex/phase2", + "browsers": { + "chromium": [ + { + "scenario": "open-default", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "open-audio-only", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "open-prefer-stream", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-add-setting-off", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-existing-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "playlist-boot-off", + "supported": true, + "saved": false, + "bytes": 0, + "calls": 0, + "errors": [] + }, + { + "scenario": "pinned-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "settings-enable", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + }, + { + "scenario": "settings-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + } + ] + }, + "failures": [], + "unsupported": [], + "buildTag": "4f7090fff60c" +} diff --git a/perf/results/autosave-phase3.json b/perf/results/autosave-phase3.json new file mode 100644 index 0000000..2c04aa5 --- /dev/null +++ b/perf/results/autosave-phase3.json @@ -0,0 +1,60 @@ +{ + "revision": "codex/phase3", + "browsers": { + "chromium": [ + { + "scenario": "open-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "pinned-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "settings-enable", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "settings-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + } + ] + }, + "failures": [], + "unsupported": [], + "buildTag": "bea1f63eba85" +} diff --git a/perf/results/autosave-phase4.json b/perf/results/autosave-phase4.json new file mode 100644 index 0000000..c5d637d --- /dev/null +++ b/perf/results/autosave-phase4.json @@ -0,0 +1,60 @@ +{ + "revision": "codex/phase4", + "browsers": { + "chromium": [ + { + "scenario": "open-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "pinned-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "settings-enable", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "settings-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + } + ] + }, + "failures": [], + "unsupported": [], + "buildTag": "c3dbdd7039db" +} diff --git a/perf/results/autosave-phase5.json b/perf/results/autosave-phase5.json new file mode 100644 index 0000000..87caf8c --- /dev/null +++ b/perf/results/autosave-phase5.json @@ -0,0 +1,60 @@ +{ + "revision": "codex/phase5", + "browsers": { + "chromium": [ + { + "scenario": "open-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "pinned-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "settings-enable", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "settings-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + } + ] + }, + "failures": [], + "unsupported": [], + "buildTag": "c3dbdd7039db" +} diff --git a/perf/results/autosave-rollback.json b/perf/results/autosave-rollback.json new file mode 100644 index 0000000..958fd12 --- /dev/null +++ b/perf/results/autosave-rollback.json @@ -0,0 +1,62 @@ +{ + "revision": "/home/josh/development/personal/ytplayer-codex/wt-autosave/frontend", + "browsers": { + "chromium": [ + { + "scenario": "open-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-add", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [] + }, + { + "scenario": "playlist-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "pinned-boot", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 0, + "errors": [] + }, + { + "scenario": "settings-enable", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + }, + { + "scenario": "settings-save-first", + "supported": true, + "saved": true, + "bytes": 30290, + "calls": 1, + "errors": [ + "Cannot read properties of null (reading 'addEventListener')" + ] + } + ] + }, + "failures": [], + "unsupported": [], + "buildTag": "2b1c433b19aa" +} From f161a5de742a81384a65e5b2befaf7b088385659 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 9 Oct 2026 14:25:02 +0800 Subject: [PATCH 2/2] Record autosave comparison results and native storage limits --- plans/autosave-investigation.md | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 plans/autosave-investigation.md diff --git a/plans/autosave-investigation.md b/plans/autosave-investigation.md new file mode 100644 index 0000000..d480741 --- /dev/null +++ b/plans/autosave-investigation.md @@ -0,0 +1,50 @@ +# Device autosave investigation — 2026-10-09 + +## Result + +No old=saves/new=does-not-save transition was reproduced. No product fix or breaking commit is justified by the available evidence. The interrupted run's commit b59fc72 already supplied the real-server harness, policy tests and phase comparison results; this continuation retained them and reran verification. + +The tested current frontend is the Phase 5 snapshot 21c1273 (parent of b59fc72), matching the requested regression scope. The shared `main` reference advanced during other queue work; this investigation did not change branches or incorporate those changes. Historical `autosave-main.json` represents main when the earlier run executed, not the subsequently advanced reference. + +## Exact behavior + +Both b77938a and Phase 5 have the same on-open gate: `saveBeforePlay:true`, video mode, no `preferStream`, web OPFS supported, and no existing saved copy. Save before playing defaults false. Audio-only and explicit streaming bypass the save. Successful saves become the playback source; failed saves fall through to streaming. + +Creating a playlist with a video or adding a video to an existing playlist calls `preload()` directly, including when autoPreload is false. On launch, autoPreload:true tops up playlists; pinned playlists top up even with the setting off. Enabling Auto-save through Settings persists the flag and starts playlist saves. The labels describe different policies; opening an arbitrary video with defaults never automatically saved in the old build either. + +References: frontend/app.js:2328 (on-open gate), :1946 (playlist policy), :1952 (pinned policy), :7107 (create playlist); frontend/views-core.js:1119 (Settings handler). + +## Evidence + +Fresh continuation commands: + +```sh +node perf/autosave.mjs --allow-unsupported-webkit --out perf/.tmp/autosave-recheck-current.json +node perf/autosave.mjs --frontend perf/fixtures/shell-1175f1a1d2c1 --asset-hashing 0 --asset-sync 0 --allow-unsupported-webkit --out perf/.tmp/autosave-recheck-old.json +node --test frontend/*.test.js +(cd server && bun install && bun run test) +``` + +Each build passed all 12 scenarios per engine. Chromium stored a complete real three-second H.264/AAC file, marked cachedIds and displayed it in Saved in all eight positive scenarios; the four negative scenarios did not save. Excerpts from both runs: + +```text +chromium open-default: PASS saved=false +chromium open-save-first: PASS saved=true +chromium playlist-add-setting-off: PASS saved=true +chromium settings-save-first: PASS saved=true +webkit settings-save-first: PASS UI/save guard only; OPFS unavailable +``` + +The Chromium settings-save-first scenario logged `Cannot read properties of null (reading 'addEventListener')` on both old and current builds despite completing the save. Assertions passing do not mean error-free UI; this shared error is a follow-up, not a newly introduced save failure. + +Linux Playwright WebKit has no native OPFS: its 12 passes establish UI/settings and capability-guard behavior only. No storage shim was used, and a genuine WebKit save remains unverified. The harness exits 2 without the explicit unsupported allowance. Thus the requested old/new/fixed comparison and a regression test failing on the allegedly broken build cannot honestly be supplied. + +Existing committed evidence under perf/results/autosave-*.json: Chromium Phase 1/2 all 12 cases, Phase 3/4/5 six positive cases, and both rollback flags six positive cases all passed. No failing phase tip exists in those runs. Frontend policy tests cover settings/pinning, duplicate saves and failed/paused saves remaining retryable. + +Continuation suites: 201 frontend tests passed; server 124 passed, two permitted worker failures due to Python ModuleNotFoundError: No module named yt_dlp. No other server failure. + +## Neighbor audit and iPhone follow-up + +Downloads, Saved, OPFS and download actions remain eager core assets. Export awaits feature:export; Share/External use the lazy facade. DirectMedia.hasSource returns false before its lazy module starts, which can select a server source instead of a paired source. P2P counts can initially appear empty before delayed startup. Neither was shown to prevent a server save. + +No runtime code changed; download bytes and startup behavior are unchanged. On the installed iPhone, verify the running build, Save before playing, audio-only and auto-save settings, then open a fresh video and separately add it to a playlist. Capture Downloads failure details, available storage, and Saved after relaunch/offline playback. Native iOS OPFS, quota/eviction and suspension during download require that device. A failing native sequence is needed to identify and fix the reported regression.