diff --git a/frontend/settings-persist.test.js b/frontend/settings-persist.test.js new file mode 100644 index 0000000..8b862c1 --- /dev/null +++ b/frontend/settings-persist.test.js @@ -0,0 +1,34 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const source = fs.readFileSync(__dirname + '/app.js', 'utf8'); +// Exercise the production persistence/profile functions without starting a DOM player. +const declaration = name => source.match(new RegExp('(?:async )?function ' + name + '\\([^]*?\\n}'))[0]; +function fixture(updatedAt) { + const settings = { saveBeforePlay: true, autoPreload: true, layout: 'f7-swipe', eq: { preset: 'custom', g: [1,2,3,4,5] } }; + const context = vm.createContext({ WEB: true, data: { settings, profile: { name: 'test', syncedAt: 200 } }, DEFAULT_SETTINGS: { saveBeforePlay: false, autoPreload: true, layout: 'classic' }, API: { saveData: async () => {} }, profileHeaders: () => ({}), scheduleProfilePush: () => context.pushes++, pushes: 0, fetch: async () => ({ status: 200, json: async () => ({ ok: true, name: 'test', updatedAt, data: { settings: { saveBeforePlay: false } } }) }), localStorage: { getItem: () => JSON.stringify({ settings }) } }); + vm.runInContext([declaration('loadDataFromStorage'), declaration('applyProfileData'), declaration('pullProfileIfNewer')].join('\n'), context); + return context; +} +test('stored settings load verbatim instead of reverting to defaults', () => { + const c = fixture(100); + const loaded = vm.runInContext('loadDataFromStorage()', c); + assert.equal(loaded.settings.saveBeforePlay, true); + assert.equal(loaded.settings.layout, 'f7-swipe'); + assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.eq.g)), [1,2,3,4,5]); +}); +for (const updatedAt of [100,200]) test(`profile timestamp ${updatedAt} cannot overwrite local settings synced at 200`, async () => { + const c = fixture(updatedAt), previous = c.data.settings; + await vm.runInContext('pullProfileIfNewer()', c); + assert.equal(c.data.settings, previous); + assert.equal(c.pushes, 1); +}); +test('newer profile intentionally replaces settings and restores missing defaults', async () => { + const c = fixture(300); + await vm.runInContext('pullProfileIfNewer()', c); + assert.equal(c.data.settings.saveBeforePlay, false); + assert.equal(c.data.settings.autoPreload, true); + assert.equal(c.data.settings.layout, 'classic'); + assert.equal(c.data.profile.syncedAt, 300); +}); diff --git a/perf/README.md b/perf/README.md index 6a79597..893b48e 100644 --- a/perf/README.md +++ b/perf/README.md @@ -385,3 +385,24 @@ The offline completeness harness compares cache contents with the runtime manifest dynamically; it must not hard-code the old 151-file inventory. See `plans/manifest-hygiene-report.md` and `perf/results/hygiene-*.json` for the before/after measurements and compatibility checks. + +## Existing-install settings preservation + +```sh +node perf/settings-persist.mjs --browser all --target main --allow-unsupported-webkit +node perf/settings-persist.mjs --browser all --target 0b8e398 --allow-unsupported-webkit --out perf/results/settings-persist-live-base.json +``` + +The harness archives b77938a and the requested target into worktree-local scratch, +serves actual Bun servers at the same origin, sets values through Settings UI +handlers, reloads, invokes the old banner's actual Refresh UI button, and diffs +every `_ytpdata.settings` key and control. A real local H.264/AAC clip verifies +OPFS storage and blob playback before/after in Chromium. A local HTTP proxy +mocks profile timestamps for worker-owned fetches on both engines and tests +backup import. There are no external profile writes or media requests. + +Failures exit 1. Linux WebKit has no native OPFS; without the explicit allowance +the unverified storage capability exits 2. The allowance only skips unsupported +storage assertions; every preservation/profile/import assertion still runs. +No storage shim substitutes for a genuine iPhone test. Snapshot results and the +per-key table are in `plans/settings-persist-investigation.md`. diff --git a/perf/results/settings-persist-live-base.json b/perf/results/settings-persist-live-base.json new file mode 100644 index 0000000..2cd5863 --- /dev/null +++ b/perf/results/settings-persist-live-base.json @@ -0,0 +1,708 @@ +{ + "revision": "0b8e39846990eb178a28746d3d111bb0790d8275", + "oldTag": "1175f1a1d2c1", + "results": [ + { + "engine": "chromium", + "errors": [ + "Cannot read properties of null (reading 'addEventListener')", + "Cannot read properties of null (reading 'addEventListener')" + ], + "profileChecks": [ + { + "stage": "older timestamp ignored", + "serverUpdatedAt": 100, + "localSyncedAt": 200, + "saveBeforePlay": true + }, + { + "stage": "older settings with newer timestamp adopted", + "serverUpdatedAt": 300, + "localSyncedAt": 200, + "saveBeforePlay": false + } + ], + "behaviour": [ + { + "stage": "old", + "supported": true, + "bytes": 30290, + "cached": true, + "localPlayback": true + }, + { + "stage": "upgraded", + "supported": true, + "bytes": 30290, + "cached": true, + "localPlayback": true + } + ], + "diff": [ + { + "key": "quality", + "before": "360p", + "after": "360p", + "equal": true, + "expectedMigration": false + }, + { + "key": "volume", + "before": 1, + "after": 1, + "equal": true, + "expectedMigration": false + }, + { + "key": "audioOnly", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "autoPreload", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "p2pShare", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "p2pReceive", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "saveBeforePlay", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "repeatMode", + "before": "all", + "after": "all", + "equal": true, + "expectedMigration": false + }, + { + "key": "loopOne", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "shuffle", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "theme", + "before": "light", + "after": "light", + "equal": true, + "expectedMigration": false + }, + { + "key": "fontScale", + "before": "large", + "after": "large", + "equal": true, + "expectedMigration": false + }, + { + "key": "levelVolume", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "liveAudioIOS", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "parallelSaves", + "before": 2, + "after": 2, + "equal": true, + "expectedMigration": false + }, + { + "key": "density", + "before": "compact", + "after": "compact", + "equal": true, + "expectedMigration": false + }, + { + "key": "perfMode", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "reduceMotion", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "layout", + "before": "f7-swipe", + "after": "f7-swipe", + "equal": true, + "expectedMigration": false + }, + { + "key": "autoBackupEnabled", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "autoBackupIntervalDays", + "before": 7, + "after": 7, + "equal": true, + "expectedMigration": false + }, + { + "key": "serviceVideo", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "foldedCards", + "before": {}, + "after": {}, + "equal": true, + "expectedMigration": false + }, + { + "key": "eq", + "before": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + }, + "after": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + }, + "equal": true, + "expectedMigration": false + } + ], + "old": { + "quality": "360p", + "volume": 1, + "audioOnly": false, + "autoPreload": true, + "p2pShare": true, + "p2pReceive": true, + "saveBeforePlay": true, + "repeatMode": "all", + "loopOne": true, + "shuffle": false, + "theme": "light", + "fontScale": "large", + "levelVolume": false, + "liveAudioIOS": false, + "parallelSaves": 2, + "density": "compact", + "perfMode": true, + "reduceMotion": true, + "layout": "f7-swipe", + "autoBackupEnabled": false, + "autoBackupIntervalDays": 7, + "serviceVideo": false, + "foldedCards": {}, + "eq": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "oldUI": { + "setSaveBeforePlay": true, + "setAutoPreload": true, + "setAudioOnly": false, + "setQuality": "360p", + "setRepeat": true, + "setLoopOne": true, + "setTheme": "light", + "setFont": "large", + "setDensity": "compact", + "setPerf": true, + "setMotion": true, + "setParallelSaves": "2", + "setLevelVolume": false, + "setLayout": "f7-swipe" + }, + "oldRaw": "{\"playlists\":[],\"history\":[],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"profile\":null}", + "newTag": "00d7fed5abcb", + "upgraded": { + "quality": "360p", + "volume": 1, + "audioOnly": false, + "autoPreload": true, + "p2pShare": true, + "p2pReceive": true, + "saveBeforePlay": true, + "repeatMode": "all", + "loopOne": true, + "shuffle": false, + "theme": "light", + "fontScale": "large", + "levelVolume": false, + "liveAudioIOS": false, + "parallelSaves": 2, + "density": "compact", + "perfMode": true, + "reduceMotion": true, + "layout": "f7-swipe", + "autoBackupEnabled": false, + "autoBackupIntervalDays": 7, + "serviceVideo": false, + "foldedCards": {}, + "eq": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "newRaw": "{\"playlists\":[],\"history\":[{\"id\":\"benchmedia01\",\"title\":\"Persisted settings local clip\",\"channel\":\"Settings probe\",\"channelId\":\"\",\"channelUrl\":\"\",\"duration\":3,\"thumbnail\":\"\"}],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{\"benchmedia01\":1},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"profile\":null}", + "newUI": { + "setSaveBeforePlay": true, + "setAutoPreload": true, + "setAudioOnly": false, + "setQuality": "360p", + "setRepeat": true, + "setLoopOne": true, + "setTheme": "light", + "setFont": "large", + "setDensity": "compact", + "setPerf": true, + "setMotion": true, + "setParallelSaves": "2", + "setLevelVolume": false, + "setLayout": "f7-swipe" + }, + "importPreserved": true, + "userDataGets": 0 + }, + { + "engine": "webkit", + "errors": [], + "profileChecks": [ + { + "stage": "older timestamp ignored", + "serverUpdatedAt": 100, + "localSyncedAt": 200, + "saveBeforePlay": true + }, + { + "stage": "older settings with newer timestamp adopted", + "serverUpdatedAt": 300, + "localSyncedAt": 200, + "saveBeforePlay": false + } + ], + "behaviour": [ + { + "stage": "old", + "supported": false, + "note": "Native OPFS unavailable; no shim" + }, + { + "stage": "upgraded", + "supported": false, + "note": "Native OPFS unavailable; no shim" + } + ], + "diff": [ + { + "key": "quality", + "before": "360p", + "after": "360p", + "equal": true, + "expectedMigration": false + }, + { + "key": "volume", + "before": 1, + "after": 1, + "equal": true, + "expectedMigration": false + }, + { + "key": "audioOnly", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "autoPreload", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "p2pShare", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "p2pReceive", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "saveBeforePlay", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "repeatMode", + "before": "all", + "after": "all", + "equal": true, + "expectedMigration": false + }, + { + "key": "loopOne", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "shuffle", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "theme", + "before": "light", + "after": "light", + "equal": true, + "expectedMigration": false + }, + { + "key": "fontScale", + "before": "large", + "after": "large", + "equal": true, + "expectedMigration": false + }, + { + "key": "levelVolume", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "liveAudioIOS", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "parallelSaves", + "before": 2, + "after": 2, + "equal": true, + "expectedMigration": false + }, + { + "key": "density", + "before": "compact", + "after": "compact", + "equal": true, + "expectedMigration": false + }, + { + "key": "perfMode", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "reduceMotion", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "layout", + "before": "f7-swipe", + "after": "f7-swipe", + "equal": true, + "expectedMigration": false + }, + { + "key": "autoBackupEnabled", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "autoBackupIntervalDays", + "before": 7, + "after": 7, + "equal": true, + "expectedMigration": false + }, + { + "key": "serviceVideo", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "foldedCards", + "before": {}, + "after": {}, + "equal": true, + "expectedMigration": false + }, + { + "key": "eq", + "before": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + }, + "after": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + }, + "equal": true, + "expectedMigration": false + } + ], + "old": { + "quality": "360p", + "volume": 1, + "audioOnly": false, + "autoPreload": true, + "p2pShare": true, + "p2pReceive": true, + "saveBeforePlay": true, + "repeatMode": "all", + "loopOne": true, + "shuffle": false, + "theme": "light", + "fontScale": "large", + "levelVolume": false, + "liveAudioIOS": false, + "parallelSaves": 2, + "density": "compact", + "perfMode": true, + "reduceMotion": true, + "layout": "f7-swipe", + "autoBackupEnabled": false, + "autoBackupIntervalDays": 7, + "serviceVideo": false, + "foldedCards": {}, + "eq": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "oldUI": { + "setSaveBeforePlay": true, + "setAutoPreload": true, + "setAudioOnly": false, + "setQuality": "360p", + "setRepeat": true, + "setLoopOne": true, + "setTheme": "light", + "setFont": "large", + "setDensity": "compact", + "setPerf": true, + "setMotion": true, + "setParallelSaves": "2", + "setLevelVolume": false, + "setLayout": "f7-swipe" + }, + "oldRaw": "{\"playlists\":[],\"history\":[],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"profile\":null}", + "newTag": "00d7fed5abcb", + "upgraded": { + "quality": "360p", + "volume": 1, + "audioOnly": false, + "autoPreload": true, + "p2pShare": true, + "p2pReceive": true, + "saveBeforePlay": true, + "repeatMode": "all", + "loopOne": true, + "shuffle": false, + "theme": "light", + "fontScale": "large", + "levelVolume": false, + "liveAudioIOS": false, + "parallelSaves": 2, + "density": "compact", + "perfMode": true, + "reduceMotion": true, + "layout": "f7-swipe", + "autoBackupEnabled": false, + "autoBackupIntervalDays": 7, + "serviceVideo": false, + "foldedCards": {}, + "eq": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "newRaw": "{\"playlists\":[],\"history\":[],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"profile\":null}", + "newUI": { + "setSaveBeforePlay": true, + "setAutoPreload": true, + "setAudioOnly": false, + "setQuality": "360p", + "setRepeat": true, + "setLoopOne": true, + "setTheme": "light", + "setFont": "large", + "setDensity": "compact", + "setPerf": true, + "setMotion": true, + "setParallelSaves": "2", + "setLevelVolume": false, + "setLayout": "f7-swipe" + }, + "importPreserved": true, + "userDataGets": 0 + } + ], + "failures": [], + "unsupported": [ + "webkit", + "webkit" + ] +} diff --git a/perf/results/settings-persist.json b/perf/results/settings-persist.json new file mode 100644 index 0000000..2b9b5fd --- /dev/null +++ b/perf/results/settings-persist.json @@ -0,0 +1,708 @@ +{ + "revision": "cf27d4f15a29747123437862ab4e461fd78178e6", + "oldTag": "1175f1a1d2c1", + "results": [ + { + "engine": "chromium", + "errors": [ + "Cannot read properties of null (reading 'addEventListener')", + "Cannot read properties of null (reading 'addEventListener')" + ], + "profileChecks": [ + { + "stage": "older timestamp ignored", + "serverUpdatedAt": 100, + "localSyncedAt": 200, + "saveBeforePlay": true + }, + { + "stage": "older settings with newer timestamp adopted", + "serverUpdatedAt": 300, + "localSyncedAt": 200, + "saveBeforePlay": false + } + ], + "behaviour": [ + { + "stage": "old", + "supported": true, + "bytes": 30290, + "cached": true, + "localPlayback": true + }, + { + "stage": "upgraded", + "supported": true, + "bytes": 30290, + "cached": true, + "localPlayback": true + } + ], + "diff": [ + { + "key": "quality", + "before": "360p", + "after": "360p", + "equal": true, + "expectedMigration": false + }, + { + "key": "volume", + "before": 1, + "after": 1, + "equal": true, + "expectedMigration": false + }, + { + "key": "audioOnly", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "autoPreload", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "p2pShare", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "p2pReceive", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "saveBeforePlay", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "repeatMode", + "before": "all", + "after": "all", + "equal": true, + "expectedMigration": false + }, + { + "key": "loopOne", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "shuffle", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "theme", + "before": "light", + "after": "light", + "equal": true, + "expectedMigration": false + }, + { + "key": "fontScale", + "before": "large", + "after": "large", + "equal": true, + "expectedMigration": false + }, + { + "key": "levelVolume", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "liveAudioIOS", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "parallelSaves", + "before": 2, + "after": 2, + "equal": true, + "expectedMigration": false + }, + { + "key": "density", + "before": "compact", + "after": "compact", + "equal": true, + "expectedMigration": false + }, + { + "key": "perfMode", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "reduceMotion", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "layout", + "before": "f7-swipe", + "after": "f7-swipe", + "equal": true, + "expectedMigration": false + }, + { + "key": "autoBackupEnabled", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "autoBackupIntervalDays", + "before": 7, + "after": 7, + "equal": true, + "expectedMigration": false + }, + { + "key": "serviceVideo", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "foldedCards", + "before": {}, + "after": {}, + "equal": true, + "expectedMigration": false + }, + { + "key": "eq", + "before": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + }, + "after": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + }, + "equal": true, + "expectedMigration": false + } + ], + "old": { + "quality": "360p", + "volume": 1, + "audioOnly": false, + "autoPreload": true, + "p2pShare": true, + "p2pReceive": true, + "saveBeforePlay": true, + "repeatMode": "all", + "loopOne": true, + "shuffle": false, + "theme": "light", + "fontScale": "large", + "levelVolume": false, + "liveAudioIOS": false, + "parallelSaves": 2, + "density": "compact", + "perfMode": true, + "reduceMotion": true, + "layout": "f7-swipe", + "autoBackupEnabled": false, + "autoBackupIntervalDays": 7, + "serviceVideo": false, + "foldedCards": {}, + "eq": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "oldUI": { + "setSaveBeforePlay": true, + "setAutoPreload": true, + "setAudioOnly": false, + "setQuality": "360p", + "setRepeat": true, + "setLoopOne": true, + "setTheme": "light", + "setFont": "large", + "setDensity": "compact", + "setPerf": true, + "setMotion": true, + "setParallelSaves": "2", + "setLevelVolume": false, + "setLayout": "f7-swipe" + }, + "oldRaw": "{\"playlists\":[],\"history\":[],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"profile\":null}", + "newTag": "ad9d32e3feb3", + "upgraded": { + "quality": "360p", + "volume": 1, + "audioOnly": false, + "autoPreload": true, + "p2pShare": true, + "p2pReceive": true, + "saveBeforePlay": true, + "repeatMode": "all", + "loopOne": true, + "shuffle": false, + "theme": "light", + "fontScale": "large", + "levelVolume": false, + "liveAudioIOS": false, + "parallelSaves": 2, + "density": "compact", + "perfMode": true, + "reduceMotion": true, + "layout": "f7-swipe", + "autoBackupEnabled": false, + "autoBackupIntervalDays": 7, + "serviceVideo": false, + "foldedCards": {}, + "eq": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "newRaw": "{\"playlists\":[],\"history\":[{\"id\":\"benchmedia01\",\"title\":\"Persisted settings local clip\",\"channel\":\"Settings probe\",\"channelId\":\"\",\"channelUrl\":\"\",\"duration\":3,\"thumbnail\":\"\"}],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{\"benchmedia01\":1},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"profile\":null}", + "newUI": { + "setSaveBeforePlay": true, + "setAutoPreload": true, + "setAudioOnly": false, + "setQuality": "360p", + "setRepeat": true, + "setLoopOne": true, + "setTheme": "light", + "setFont": "large", + "setDensity": "compact", + "setPerf": true, + "setMotion": true, + "setParallelSaves": "2", + "setLevelVolume": false, + "setLayout": "f7-swipe" + }, + "importPreserved": true, + "userDataGets": 0 + }, + { + "engine": "webkit", + "errors": [], + "profileChecks": [ + { + "stage": "older timestamp ignored", + "serverUpdatedAt": 100, + "localSyncedAt": 200, + "saveBeforePlay": true + }, + { + "stage": "older settings with newer timestamp adopted", + "serverUpdatedAt": 300, + "localSyncedAt": 200, + "saveBeforePlay": false + } + ], + "behaviour": [ + { + "stage": "old", + "supported": false, + "note": "Native OPFS unavailable; no shim" + }, + { + "stage": "upgraded", + "supported": false, + "note": "Native OPFS unavailable; no shim" + } + ], + "diff": [ + { + "key": "quality", + "before": "360p", + "after": "360p", + "equal": true, + "expectedMigration": false + }, + { + "key": "volume", + "before": 1, + "after": 1, + "equal": true, + "expectedMigration": false + }, + { + "key": "audioOnly", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "autoPreload", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "p2pShare", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "p2pReceive", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "saveBeforePlay", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "repeatMode", + "before": "all", + "after": "all", + "equal": true, + "expectedMigration": false + }, + { + "key": "loopOne", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "shuffle", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "theme", + "before": "light", + "after": "light", + "equal": true, + "expectedMigration": false + }, + { + "key": "fontScale", + "before": "large", + "after": "large", + "equal": true, + "expectedMigration": false + }, + { + "key": "levelVolume", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "liveAudioIOS", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "parallelSaves", + "before": 2, + "after": 2, + "equal": true, + "expectedMigration": false + }, + { + "key": "density", + "before": "compact", + "after": "compact", + "equal": true, + "expectedMigration": false + }, + { + "key": "perfMode", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "reduceMotion", + "before": true, + "after": true, + "equal": true, + "expectedMigration": false + }, + { + "key": "layout", + "before": "f7-swipe", + "after": "f7-swipe", + "equal": true, + "expectedMigration": false + }, + { + "key": "autoBackupEnabled", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "autoBackupIntervalDays", + "before": 7, + "after": 7, + "equal": true, + "expectedMigration": false + }, + { + "key": "serviceVideo", + "before": false, + "after": false, + "equal": true, + "expectedMigration": false + }, + { + "key": "foldedCards", + "before": {}, + "after": {}, + "equal": true, + "expectedMigration": false + }, + { + "key": "eq", + "before": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + }, + "after": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + }, + "equal": true, + "expectedMigration": false + } + ], + "old": { + "quality": "360p", + "volume": 1, + "audioOnly": false, + "autoPreload": true, + "p2pShare": true, + "p2pReceive": true, + "saveBeforePlay": true, + "repeatMode": "all", + "loopOne": true, + "shuffle": false, + "theme": "light", + "fontScale": "large", + "levelVolume": false, + "liveAudioIOS": false, + "parallelSaves": 2, + "density": "compact", + "perfMode": true, + "reduceMotion": true, + "layout": "f7-swipe", + "autoBackupEnabled": false, + "autoBackupIntervalDays": 7, + "serviceVideo": false, + "foldedCards": {}, + "eq": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "oldUI": { + "setSaveBeforePlay": true, + "setAutoPreload": true, + "setAudioOnly": false, + "setQuality": "360p", + "setRepeat": true, + "setLoopOne": true, + "setTheme": "light", + "setFont": "large", + "setDensity": "compact", + "setPerf": true, + "setMotion": true, + "setParallelSaves": "2", + "setLevelVolume": false, + "setLayout": "f7-swipe" + }, + "oldRaw": "{\"playlists\":[],\"history\":[],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"profile\":null}", + "newTag": "ad9d32e3feb3", + "upgraded": { + "quality": "360p", + "volume": 1, + "audioOnly": false, + "autoPreload": true, + "p2pShare": true, + "p2pReceive": true, + "saveBeforePlay": true, + "repeatMode": "all", + "loopOne": true, + "shuffle": false, + "theme": "light", + "fontScale": "large", + "levelVolume": false, + "liveAudioIOS": false, + "parallelSaves": 2, + "density": "compact", + "perfMode": true, + "reduceMotion": true, + "layout": "f7-swipe", + "autoBackupEnabled": false, + "autoBackupIntervalDays": 7, + "serviceVideo": false, + "foldedCards": {}, + "eq": { + "preset": "bass", + "g": [ + 5, + 7, + 5, + 3, + 1, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "newRaw": "{\"playlists\":[],\"history\":[],\"queue\":[],\"customVideos\":[],\"pinnedPlaylists\":[],\"settings\":{\"quality\":\"360p\",\"volume\":1,\"audioOnly\":false,\"autoPreload\":true,\"p2pShare\":true,\"p2pReceive\":true,\"saveBeforePlay\":true,\"repeatMode\":\"all\",\"loopOne\":true,\"shuffle\":false,\"theme\":\"light\",\"fontScale\":\"large\",\"levelVolume\":false,\"liveAudioIOS\":false,\"parallelSaves\":2,\"density\":\"compact\",\"perfMode\":true,\"reduceMotion\":true,\"layout\":\"f7-swipe\",\"autoBackupEnabled\":false,\"autoBackupIntervalDays\":7,\"serviceVideo\":false,\"foldedCards\":{},\"eq\":{\"preset\":\"bass\",\"g\":[5,7,5,3,1,0,0,0,0,0]}},\"resumePositions\":{},\"rememberPos\":{},\"playCount\":{},\"abMarkers\":{},\"bookmarks\":{},\"lyricOffsets\":{},\"stats\":null,\"lastAutoBackup\":0,\"profile\":null}", + "newUI": { + "setSaveBeforePlay": true, + "setAutoPreload": true, + "setAudioOnly": false, + "setQuality": "360p", + "setRepeat": true, + "setLoopOne": true, + "setTheme": "light", + "setFont": "large", + "setDensity": "compact", + "setPerf": true, + "setMotion": true, + "setParallelSaves": "2", + "setLevelVolume": false, + "setLayout": "f7-swipe" + }, + "importPreserved": true, + "userDataGets": 0 + } + ], + "failures": [], + "unsupported": [ + "webkit", + "webkit" + ] +} diff --git a/perf/settings-persist.mjs b/perf/settings-persist.mjs new file mode 100644 index 0000000..7e0838f --- /dev/null +++ b/perf/settings-persist.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// Existing-install migration and profile/import settings gate; all servers and media stay local. +import assert from 'node:assert/strict'; +import {spawn,execFileSync} from 'node:child_process'; +import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,symlinkSync,unlinkSync,rmSync,statSync} from 'node:fs'; +import {join,resolve} from 'node:path'; +import {createServer} from 'node:net'; +import {createServer as createHttpServer, request as httpRequest} from 'node:http'; +import {createRequire} from 'node:module'; +import {chromium,webkit} from 'playwright'; +const repo=resolve(import.meta.dirname,'..'),args=process.argv.slice(2); +const opt=(k,d)=>args.includes(k)?args[args.indexOf(k)+1]:d; +const oldTag='1175f1a1d2c1',target=opt('--target','main'),engine=opt('--browser','all'); +assert.ok(['all','chromium','webkit'].includes(engine)); +execFileSync('bash',[join(repo,'perf/make-shell-fixture.sh'),'b77938a',oldTag],{cwd:repo,stdio:'inherit'}); +const scratch=mkdtempSync(join(repo,'perf/.tmp/settings-')); +function archive(rev,dest){mkdirSync(dest,{recursive:true});const tar=join(dest,'source.tar');writeFileSync(tar,execFileSync('git',['archive',rev,'frontend','server'],{cwd:repo,maxBuffer:40*1024*1024}));execFileSync('tar',['-xf',tar,'-C',dest]);symlinkSync(join(repo,'server/node_modules'),join(dest,'server/node_modules'));} +const revision=execFileSync('git',['rev-parse',target],{cwd:repo,encoding:'utf8'}).trim(); +archive('b77938a',join(scratch,'old'));archive(revision,join(scratch,'new')); +symlinkSync(join(repo,'perf/fixtures/shell-'+oldTag),join(scratch,'old/server/public')); +symlinkSync(join(scratch,'new/frontend'),join(scratch,'new/server/public')); +const results={revision,oldTag,results:[],failures:[],unsupported:[]}; +const sleep=ms=>new Promise(r=>setTimeout(r,ms)); +async function poll(fn,timeout=90000){const until=Date.now()+timeout;while(Date.now()sock.listen(0,'127.0.0.1',r));const port=sock.address().port;await new Promise(r=>sock.close(r));let origin='http://127.0.0.1:'+port; + let child,browser,proxy,profileReply=null,userDataGets=0,log=''; + const result={engine:name,errors:[],profileChecks:[],behaviour:[],diff:[]};results.results.push(result); + async function stop(){if(!child)return;const c=child;child=null;if(c.exitCode===null){c.kill('SIGTERM');await new Promise(r=>c.once('exit',r));}} + async function start(old){symlinkSync(old?join(repo,'perf/fixtures/shell-'+oldTag):join(scratch,'new/frontend'),join(cwd,'public'));child=spawn('bun',[join(scratch,old?'old':'new','server/server.js')],{cwd,env:{...process.env,PORT:String(port),DB_PATH:join(cwd,'db.sqlite'),MEDIA_DIR:join(cwd,'media'),UPLOAD_DIR:join(cwd,'uploads'),DATA_DIR:join(cwd,'data'),ASSET_HASHING:old?'0':'1',ASSET_SYNC:old?'0':'1',BUILD_TAG:''},stdio:['ignore','pipe','pipe']});for(const s of [child.stdout,child.stderr])s.on('data',c=>log=(log+c).slice(-5000));await poll(async()=>{if(child.exitCode!==null)throw Error(log);try{return(await fetch(origin+'/api/version')).ok;}catch{return false;}});} + try{ + await start(true); + const clip={id:'benchmedia01',title:'Persisted settings local clip',channel:'Settings probe',duration:3,thumbnail:''}; + const mp4=join(cwd,'media',clip.id+'.1.mp4');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',join(cwd,'media',clip.id+'.1.m4a')],{stdio:'ignore'}); + const require=createRequire(import.meta.url),{createClient}=require(join(repo,'server/node_modules/@libsql/client')); + const db=createClient({url:'file:'+join(cwd,'db.sqlite')});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(); + browser=await({chromium,webkit}[name]).launch({headless:true});const context=await browser.newContext({viewport:{width:414,height:896}}); + // Server-side mocks also cover WebKit worker-owned fetches, which bypass page routing. + proxy=createHttpServer((req,res)=>{ + const pathname=new URL(req.url,'http://local').pathname; + if(pathname==='/api/user/data'){userDataGets++;res.setHeader('Content-Type','application/json');res.end(JSON.stringify({ok:true,data:{settings:{saveBeforePlay:false}}}));return;} + if(pathname==='/api/profile/load'&&profileReply){res.setHeader('Cache-Control','no-store');res.setHeader('Content-Type','application/json');res.end(JSON.stringify(profileReply));return;} + if(pathname==='/api/profile/save'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({ok:false,error:'probe blocks writes'}));return;} + const upstream=httpRequest({hostname:'127.0.0.1',port,path:req.url,method:req.method,headers:req.headers},r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res);});upstream.on('error',()=>{res.statusCode=503;res.end();});req.pipe(upstream); + });await new Promise(r=>proxy.listen(0,'127.0.0.1',r));origin='http://127.0.0.1:'+proxy.address().port; + await context.route('**/*',route=>route.request().url().startsWith(origin)?route.continue():route.abort()); + const page=await context.newPage();page.on('pageerror',e=>result.errors.push(e.message)); + const boot=async()=>{await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#sidebarToggle'));await page.waitForTimeout(800);}; + const settings=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('_ytpdata')).settings); + const openSettings=async()=>{await page.locator('[data-view="settings"]').first().evaluate(e=>e.click());await page.waitForSelector('#setSaveBeforePlay',{state:'attached'});}; + const readUI=()=>page.evaluate(ids=>Object.fromEntries(ids.map(id=>{const e=document.getElementById(id);return[id,e.type==='checkbox'?e.checked:e.value];})),Object.values(ui).map(x=>x[0])); + await boot();await page.waitForFunction(()=>!!navigator.serviceWorker.controller); + await openSettings(); + for(const [key,[id,value]]of Object.entries(ui)){await page.locator('#'+id).evaluate((e,value)=>{if(e.type==='checkbox')e.checked=value;else e.value=value;e.dispatchEvent(new Event('change',{bubbles:true}));},value);} + await page.evaluate(()=>EQ.open());await page.waitForSelector('[data-preset="bass"]',{state:'attached'});await page.locator('[data-preset="bass"]').click();await page.evaluate(()=>closeModal());await sleep(600); + await boot();await openSettings();result.old=await settings();result.oldUI=await readUI();result.oldRaw=await page.evaluate(()=>localStorage.getItem('_ytpdata')); + async function behaviour(stage){const supported=await page.evaluate(()=>OPFS.isSupported());if(!supported){result.behaviour.push({stage,supported:false,note:'Native OPFS unavailable; no shim'});results.unsupported.push(name);return;} + await page.evaluate(async id=>{await OPFS.deleteVideo(id);cachedIds.delete(id);Player.pause();},clip.id); + await page.evaluate(clip=>Player.loadVideo(clip),clip);await page.waitForFunction(id=>cachedIds.has(id)&&!!current?.localUrl,clip.id,{timeout:20000}); + const state=await page.evaluate(async id=>({cached:cachedIds.has(id),files:await OPFS.listVideos(),localUrl:current.localUrl,src:Player.master?.src}),clip.id);assert.equal(state.files.find(f=>f.id===clip.id)?.size,statSync(mp4).size);assert.ok(state.src?.startsWith('blob:'),'player must use OPFS blob');await page.waitForFunction(()=>Player.master?.readyState>=3);await page.evaluate(()=>Player.play());await page.waitForFunction(()=>Player.master?.currentTime>0);await page.evaluate(()=>Player.pause());result.behaviour.push({stage,supported:true,bytes:state.files.find(f=>f.id===clip.id).size,cached:true,localPlayback:true});} + await behaviour('old');console.log(name,'old settings and save verified');await page.evaluate(()=>Player.pause()); + await stop();unlinkSync(join(cwd,'public'));await start(false);result.newTag=(await(await fetch(origin+'/api/version')).json()).buildTag; + await page.evaluate(async()=>{await checkBuildTag();await(await navigator.serviceWorker.getRegistration()).update();});await poll(()=>page.evaluate(async()=>!!(await navigator.serviceWorker.getRegistration())?.waiting)); + await page.waitForFunction(()=>!document.getElementById('modal').classList.contains('hidden')&&/Update available/.test(document.getElementById('modalTitle').textContent)); + await page.locator('#modal').getByRole('button',{name:'Refresh UI',exact:true}).evaluate(e=>e.click());await page.waitForFunction(tag=>document.querySelector('meta[name="ytp-build"]')?.content===tag,result.newTag,{timeout:90000});await sleep(1000);await openSettings(); + console.log(name,'real Refresh UI completed');result.upgraded=await settings();result.newRaw=await page.evaluate(()=>localStorage.getItem('_ytpdata'));result.newUI=await readUI();assert.deepEqual(result.newUI,result.oldUI,'checkbox/select values preserved'); + const {migrate}=require(join(scratch,'new/frontend/eq-parameters.js'));const expected=structuredClone(result.old);if(expected.eq?.g?.length===5)expected.eq.g=migrate(expected.eq.g); + for(const key of new Set([...Object.keys(result.old),...Object.keys(result.upgraded)]))result.diff.push({key,before:result.old[key],after:result.upgraded[key],equal:JSON.stringify(result.old[key])===JSON.stringify(result.upgraded[key]),expectedMigration:key==='eq'&&result.old.eq?.g?.length===5}); + assert.deepEqual(result.upgraded,expected,'all persisted settings preserved except deliberate 5→10 EQ migration');await behaviour('upgraded'); + const local=await settings(),remote={...local,saveBeforePlay:false,autoPreload:false,layout:'classic',quality:'auto'}; + async function profileCheck(updatedAt,expectedSave,stage){profileReply={ok:true,name:'probe',updatedAt,data:{settings:remote,playlists:[]}};await page.evaluate(s=>{data.settings=s;data.profile={name:'probe',syncedAt:200};localStorage.setItem('_ytpdata',JSON.stringify(data));},local);await boot();await page.waitForFunction(()=>_syncBusy===0);await openSettings();const s=await settings();assert.equal(s.saveBeforePlay,expectedSave);assert.equal(await page.locator('#setSaveBeforePlay').isChecked(),expectedSave);if(!expectedSave)assert.equal(s.layout,'classic');else assert.deepEqual(s,local);result.profileChecks.push({stage,serverUpdatedAt:updatedAt,localSyncedAt:200,saveBeforePlay:s.saveBeforePlay});} + await profileCheck(100,true,'older timestamp ignored');await profileCheck(300,false,'older settings with newer timestamp adopted'); + profileReply=null;await page.evaluate(s=>{data.profile=null;data.settings=s;localStorage.setItem('_ytpdata',JSON.stringify(data));},local);await boot();await openSettings(); + await page.locator('#backupFileInput').setInputFiles({name:'settings-probe.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify({playlists:[],settings:remote}))});await page.waitForFunction(()=>document.getElementById('modalTitle').textContent==='Import backup?');await page.locator('#modal').getByRole('button',{name:'Import',exact:true}).evaluate(e=>e.click());await sleep(600);assert.deepEqual(await settings(),local,'backup import does not replace settings');result.importPreserved=true;result.userDataGets=userDataGets;assert.equal(userDataGets,0,'boot does not pull settings from fingerprint user-data endpoint'); + console.log(name,JSON.stringify({keys:result.diff.length,changed:result.diff.filter(x=>!x.equal),behaviour:result.behaviour,profileChecks:result.profileChecks,importPreserved:result.importPreserved,errors:result.errors})); + }finally{await browser?.close();await stop();if(proxy){proxy.closeAllConnections();await new Promise(r=>proxy.close(r));}} +} +try{for(const name of ['chromium','webkit'])if(engine==='all'||engine===name){try{await run(name);}catch(e){results.failures.push({engine:name,error:e.stack});console.error(name,e.message);}}}finally{const out=resolve(opt('--out','perf/results/settings-persist.json'));mkdirSync(resolve(out,'..'),{recursive:true});writeFileSync(out,JSON.stringify(results,null,2)+'\n');rmSync(scratch,{recursive:true,force:true});} +if(results.failures.length)process.exitCode=1;else if(results.unsupported.length&&!args.includes('--allow-unsupported-webkit')){console.error('Native WebKit OPFS unverified; use --allow-unsupported-webkit for persistence/UI coverage');process.exitCode=2;} diff --git a/plans/settings-persist-investigation.md b/plans/settings-persist-investigation.md new file mode 100644 index 0000000..116cda0 --- /dev/null +++ b/plans/settings-persist-investigation.md @@ -0,0 +1,131 @@ +# Persisted settings across production updates + +## Result + +No Phase 1–5 persistence regression was reproduced. No runtime fix or breaking +commit is justified. The old b77938a shell (verified tag `1175f1a1d2c1`) upgraded +through its own banner/Refresh UI flow at the same origin to current main +`cf27d4f15a29747123437862ab4e461fd78178e6`. All 24 existing setting keys and all 14 checked controls were preserved +on Chromium and WebKit. The original worktree base/live deployment snapshot +`0b8e398` was also tested separately: both engines preserve the same 24/24 keys, +controls, profile/import policy; Chromium again saves and plays the complete +clip before/after. This avoids conflating concurrent main changes. + +Both servers are actual archived Bun implementations, with isolated DB/media +directories. A local proxy supplies deliberately older profile copies and blocks +profile writes; no credentials or real online profiles are used. Settings values +were set using real controls/change handlers, and EQ using its actual preset +button. The installed page was reloaded before recording its exact `_ytpdata` +JSON; old/new raw snapshots are in the results file. Refresh UI and import use +the existing button handlers via `.click()` on the DOM element: physical +Playwright hit-testing was obstructed by the bottom nav at large font size. +This tests persistence/update handlers, not modal pointer geometry. + +## Every-key comparison + +Values below are identical on both engines; no key was removed or reset. + +| Key | Old (after reload) | Upgraded | Status | +| --- | --- | --- | --- | +| quality | `"360p"` | `"360p"` | same | +| volume | `1` | `1` | same | +| audioOnly | `false` | `false` | same | +| autoPreload | `true` | `true` | same | +| p2pShare | `true` | `true` | same | +| p2pReceive | `true` | `true` | same | +| saveBeforePlay | `true` | `true` | same | +| repeatMode | `"all"` | `"all"` | same | +| loopOne | `true` | `true` | same | +| shuffle | `false` | `false` | same | +| theme | `"light"` | `"light"` | same | +| fontScale | `"large"` | `"large"` | same | +| levelVolume | `false` | `false` | same | +| liveAudioIOS | `false` | `false` | same | +| parallelSaves | `2` | `2` | same | +| density | `"compact"` | `"compact"` | same | +| perfMode | `true` | `true` | same | +| reduceMotion | `true` | `true` | same | +| layout | `"f7-swipe"` | `"f7-swipe"` | same | +| autoBackupEnabled | `false` | `false` | same | +| autoBackupIntervalDays | `7` | `7` | same | +| serviceVideo | `false` | `false` | same | +| foldedCards | `{}` | `{}` | same | +| eq | `{"preset":"bass","g":[5,7,5,3,1,0,0,0,0,0]}` | `{"preset":"bass","g":[5,7,5,3,1,0,0,0,0,0]}` | same | + +Save before playing and Auto-save playlist videos stayed ON; audio-only OFF. +Quality stayed 360p, layout f7-swipe, loop/repeat ON, light theme, large font, +compact density, performance/reduced motion ON and two parallel saves. +The tested b77938a build already has ten-band EQ, so its Bass boost curve was +unchanged. The five-band compatibility migration in app.js was not triggered. + +## Behaviour and alternative overwrite paths + +Chromium saved the complete 30,290-byte H.264/AAC clip before and after the +upgrade, set cachedIds, used an OPFS blob source, reached readyState ≥3 and +advanced playback time. The existing copy was deleted before the second open +to force a genuinely new post-upgrade download. Linux WebKit reports native +OPFS unsupported on both builds; preservation/UI checks pass but successful +native storage/playback there is not claimed or shimmed. + +Profile tests exercise the launch path with `syncedAt:200`: + +| Server updatedAt | Server Save before playing | Device result, both engines | +| ---: | --- | --- | +| 100 | false | Local true and every local setting retained | +| 300 | false | Server false adopted; layout classic and defaults/remote values applied | + +A copy with old *values* but a newer timestamp can therefore disable autosave. +This is existing last-write-wins behavior, not an update migration regression: +`pullProfileIfNewer()` and `applyProfileData()` at b77938a already implement the +same timestamp comparison and whole-settings replacement. Equal timestamps +also retain local settings (unit test). A genuine older timestamp cannot reset +local settings in these tests. + +`API.loadData()` reads localStorage in web mode; boot overlays loaded.settings +on DEFAULT_SETTINGS. Phase 4 extraction does not create a second data object. +The Phase 3 head loader only reads `_ytpdata` and sets appearance attributes; +it never writes settings. `syncOnLaunch()` invokes profile reconciliation +after the first render. `/api/user/data` was monitored/mocked with false +autosave settings and received zero requests: fingerprint sync is not a settings +pull at launch. `saveDataToStorage()` pushes metadata to `/api/user/sync`, +without a settings field. + +Importing a backup containing opposite settings through the actual file input +and Import button preserved the current settings. `importBackup()` merges +playlists/history/positions but does not apply `imp.settings`. Intentional +profile adoption/share-link loading uses `applyProfileData()` and *does* replace +settings; this is already documented on the existing confirmation UI. + +## Verification + +```sh +node perf/settings-persist.mjs --browser all --target main --allow-unsupported-webkit +node perf/settings-persist.mjs --browser all --target 0b8e398 --allow-unsupported-webkit --out perf/results/settings-persist-live-base.json +node --test frontend/*.test.js +(cd server && bun install && bun run test) +``` + +Without `--allow-unsupported-webkit`, unverified native WebKit storage exits 2. +Any assertion failure exits 1 even with that allowance. The server-side proxy +is essential: WebKit worker network requests bypass page-route mocks. + +The two shared Chromium Settings errors (`null.addEventListener`) are recorded +in the results; they already occur on the old build. They did not stop the +playback/autosave handlers from persisting or saving. This is not an assertion +that every Settings interaction is error-free. + +Only tests, harness and documentation changed; no runtime implementation or +save behavior changed. A phase bisect and failing-then-fixed runtime test cannot be supplied +honestly because no setting loss was reproduced on either target. + +## Phone checks + +On the installed PWA, inspect Settings → Playback → Save before playing, ensure +Audio-only is OFF and no explicit streaming override is selected. Auto-save +playlist videos is a separate policy. Check the linked profile and whether +another device/imported profile pushed a newer copy with the toggle OFF. +Compare settings before/after an online launch. Re-check origin/PWA identity, +iOS Clear Website Data/storage eviction/private mode, free quota and Downloads +failure details. Native iPhone OPFS, suspension, quota sharing and storage reset +remain unverified by Linux WebKit. A phone-side failing sequence/settings export +is needed before changing the sync policy or save logic.