import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; import puppeteer from 'puppeteer-core'; const executablePath = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const model = await readFile('extension/shared/theme-model.js', 'utf8'); const runtime = await readFile('extension/shared/theme-runtime.js', 'utf8'); const integratedSettings = await readFile('extension/shared/integrated-settings.js', 'utf8'); test('themes cover stylesheet, inline, SVG, pseudo-element and dynamic states, then restore native styles', async () => { const browser = await puppeteer.launch({ executablePath, headless: true }); const page = await browser.newPage(); try { await page.goto('https://example.com'); await page.setContent(`
handle
Link
Text
Custom text and background
Composer
`); await page.evaluate(() => { window.listeners = []; window.initialThemeRead = new Promise(resolve => { window.resolveInitialThemeRead = resolve; }); window.chrome = { storage: { local: { get: () => window.initialThemeRead }, onChanged: { addListener: fn => window.listeners.push(fn) }, } }; }); // UUIDs are supplied here so fixtures also work in restricted browser environments. await page.addScriptTag({ content: model }); await page.addScriptTag({ content: runtime }); const original = await page.$eval('#inline', el => el.getAttribute('style')); const photo = await page.$eval('#photo', el => el.src); await page.evaluate(() => { const m = window.BskyPlusThemes; const theme = { schemaVersion: 1, id: 'test-theme', name: 'Test theme', base: 'dark', colors: { ...m.PRESETS.dark, background: '#102030', surface: '#203040', surfaceRaised: '#304050', text: '#F1F2F3', textMuted: '#AABBCC', textSubtle: '#8899AA', border: '#556677', primary: '#AA3300', primaryHover: '#BB4400', link: '#55DDFF', onPrimary: '#EEEECC', like: '#FF55AA', positive: '#33CC88', overlay: '#112233', shadow: '#223344', } }; window.library = { schemaVersion: 1, activeId: theme.id, themes: [theme] }; window.listeners.forEach(fn => fn({ bskyplusThemes: { newValue: window.library } }, 'local')); window.resolveInitialThemeRead({ bskyplusThemes: window.library }); }); const color = (selector, property, pseudo) => page.$eval(selector, (el, property, pseudo) => getComputedStyle(el, pseudo)[property], property, pseudo); await page.waitForFunction(() => getComputedStyle(document.body).backgroundColor === 'rgb(16, 32, 48)'); assert.equal(await color('body', 'backgroundColor'), 'rgb(16, 32, 48)'); assert.equal(await color('.post', 'backgroundColor'), 'rgb(32, 48, 64)'); assert.equal(await color('.post', 'borderTopColor'), 'rgb(85, 102, 119)'); assert.equal(await color('.composer-edge', 'borderTopColor'), 'rgba(0, 0, 0, 0)'); assert.equal(await color('.composer-edge', 'borderBottomColor'), 'rgba(0, 0, 0, 0)'); await page.$eval('.composer-edge', element => { element.style.borderTopColor = '#DCE2EA'; element.style.borderBottomColor = '#DCE2EA'; }); await page.waitForFunction(() => getComputedStyle(document.querySelector('.composer-edge')).borderTopColor === 'rgb(85, 102, 119)'); assert.equal(await color('.composer-edge', 'borderBottomColor'), 'rgb(85, 102, 119)'); await page.$eval('.composer-edge', element => { element.style.borderTopColor = 'transparent'; element.style.borderBottomColor = 'transparent'; }); await page.waitForFunction(() => getComputedStyle(document.querySelector('.composer-edge')).borderTopColor === 'rgba(0, 0, 0, 0)'); assert.equal(await color('.composer-edge', 'borderBottomColor'), 'rgba(0, 0, 0, 0)'); assert.equal(await color('.muted', 'color'), 'rgb(170, 187, 204)'); assert.equal(await color('.primary', 'backgroundColor'), 'rgb(170, 51, 0)'); assert.equal(await color('.white', 'color'), 'rgb(238, 238, 204)'); await page.hover('.primary'); assert.equal(await color('.primary', 'backgroundColor'), 'rgb(187, 68, 0)'); assert.equal(await color('input', 'color', '::placeholder'), 'rgb(136, 153, 170)'); assert.equal(await color('.media', 'color'), 'rgb(85, 221, 255)'); assert.equal(await color('#inline', 'backgroundColor'), 'rgb(48, 64, 80)'); assert.equal(await color('#root', 'color'), 'rgb(18, 52, 86)'); assert.equal(await color('#root', 'backgroundColor'), 'rgba(0, 0, 0, 0)'); assert.equal(await color('#like', 'fill'), 'rgb(255, 85, 170)'); assert.equal(await color('#repost', 'stroke'), 'rgb(51, 204, 136)'); assert.equal(await color('#verified', 'fill'), 'rgb(238, 238, 204)'); assert.equal(await color('#overlay', 'backgroundColor'), 'rgba(17, 34, 51, 0.6)'); assert.match(await color('.shadow', 'boxShadow'), /rgba\(34, 51, 68, 0.4\)/); assert.equal(await page.$eval('#inline', el => el.getAttribute('style')), original); await page.$eval('#root', element => { element.style.color = '#234567'; element.style.backgroundColor = '#654321'; }); await page.waitForFunction(() => { const style = getComputedStyle(document.querySelector('#root')); return style.color === 'rgb(35, 69, 103)' && style.backgroundColor === 'rgb(101, 67, 33)'; }); await page.$eval('#root', element => { element.style.removeProperty('color'); element.style.removeProperty('background-color'); }); await page.waitForFunction(() => { const style = getComputedStyle(document.querySelector('#root')); return style.color === 'rgb(241, 242, 243)' && style.backgroundColor === 'rgb(16, 32, 48)'; }); assert.equal(await page.$eval('#photo', el => el.src), photo); assert.match(await color('#art', 'backgroundImage'), /rgb\(0, 106, 255\)/); assert.match(await color('#fade', 'backgroundImage'), /rgb\(16, 32, 48\)/); await page.evaluate(() => { window.previewStartedAt = performance.now(); const preview = structuredClone(window.library.themes[0]); preview.id = 'preview-theme'; preview.colors.background = '#654321'; window.dispatchEvent(new CustomEvent('bskyplus:theme-preview', { detail: preview })); }); await page.waitForFunction(() => document.getAnimations().some(animation => animation.effect.pseudoElement === '::view-transition-old(root)' || animation.effect.pseudoElement === '::view-transition-new(root)')); const previewState = await page.evaluate(() => ({ background: getComputedStyle(document.body).backgroundColor, elementAnimations: document.body.getAnimations().length, durations: [...new Set(document.getAnimations() .filter(animation => animation.effect.pseudoElement === '::view-transition-old(root)' || animation.effect.pseudoElement === '::view-transition-new(root)') .map(animation => animation.effect.getTiming().duration))], })); assert.equal(previewState.background, 'rgb(101, 67, 33)'); assert.equal(previewState.elementAnimations, 0); assert.deepEqual(previewState.durations, [1000]); assert.equal(await page.$eval('html', element => element.getAttribute('data-bskyplus-theme-active')), 'preview-theme'); await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]); await page.waitForFunction(() => !document.getAnimations().some(animation => animation.effect.pseudoElement?.startsWith('::view-transition'))); const cancellationElapsed = await page.evaluate(() => performance.now() - window.previewStartedAt); assert.ok(cancellationElapsed < 900, `reduced motion cancels the active transition promptly (${cancellationElapsed}ms)`); await page.evaluate(() => { const preview = structuredClone(window.library.themes[0]); preview.id = 'reduced-preview-theme'; preview.colors.background = '#123456'; window.dispatchEvent(new CustomEvent('bskyplus:theme-preview', { detail: preview })); }); await page.waitForFunction(() => document.documentElement.dataset.bskyplusThemeActive === 'reduced-preview-theme'); assert.equal(await page.evaluate(() => document.getAnimations().some(animation => animation.effect.pseudoElement?.startsWith('::view-transition'))), false); assert.equal(await color('body', 'backgroundColor'), 'rgb(18, 52, 86)'); await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'no-preference' }]); await page.evaluate(() => window.dispatchEvent(new CustomEvent('bskyplus:theme-preview', { detail: null }))); assert.equal(await color('body', 'backgroundColor'), 'rgb(16, 32, 48)'); assert.equal(await page.$eval('html', element => element.getAttribute('data-bskyplus-theme-active')), 'test-theme'); await page.evaluate(() => { document.querySelector('#inline').style.backgroundColor = '#F9FAFB'; const extra = document.createElement('p'); extra.id = 'dynamic'; extra.style.color = '#EC4899'; document.body.append(extra); document.styleSheets[0].insertRule('.late { color: #006AFF; }'); const late = document.createElement('span'); late.className = 'late'; document.body.append(late); }); await page.waitForFunction(() => getComputedStyle(document.querySelector('#dynamic')).color === 'rgb(255, 85, 170)'); assert.equal(await color('#inline', 'backgroundColor'), 'rgb(32, 48, 64)'); await page.waitForFunction(() => getComputedStyle(document.querySelector('.late')).color === 'rgb(85, 221, 255)'); await page.evaluate(() => { history.replaceState(null, '', '/settings'); const native = document.createElement('a'); native.href = '/settings/about'; const label = document.createElement('span'); label.dir = 'auto'; label.style.color = '#000000'; label.textContent = 'About'; native.append(label); document.body.append(native); }); await page.addScriptTag({ content: integratedSettings }); await page.waitForSelector('[data-bskyplus-settings-link]'); await page.evaluate(() => { window.library.themes[0].colors.text = '#ABCDEF'; window.listeners.forEach(fn => fn({ bskyplusThemes: { newValue: window.library } }, 'local')); }); await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-bskyplus-settings-label]')).color === 'rgb(171, 205, 239)', { timeout: 2000 }); assert.equal(await color('[data-bskyplus-settings-icon] path', 'fill'), 'rgb(171, 205, 239)'); await page.evaluate(() => { document.documentElement.className = 'theme--dim'; document.querySelector('#inline').style.backgroundColor = '#151D28'; }); await page.waitForFunction(() => getComputedStyle(document.querySelector('#inline')).backgroundColor === 'rgb(16, 32, 48)'); await page.evaluate(() => { window.listeners.forEach(fn => fn({ bskyplusThemes: { newValue: { ...window.library, activeId: null } } }, 'local')); }); await page.waitForFunction(() => !document.documentElement.hasAttribute('data-bskyplus-theme-active')); assert.equal(await page.$('#bskyplus-theme-style'), null); assert.equal(await page.$('[data-bskyplus-theme-inline]'), null); assert.equal(await color('.post', 'backgroundColor'), 'rgb(249, 250, 251)'); assert.equal(await color('#like', 'fill'), 'rgb(236, 72, 153)'); assert.equal(await color('#inline', 'backgroundColor'), 'rgb(21, 29, 40)'); assert.equal(await color('#root', 'backgroundColor'), 'rgb(249, 250, 251)'); assert.equal(await page.$eval('#root', element => element.getAttribute('style')), ''); assert.equal(await color('[data-bskyplus-settings-label]', 'color'), 'rgb(0, 0, 0)'); } finally { await browser.close(); } }); test('runtime applies a persisted built-in theme ID without adding it to the saved library', async () => { const browser = await puppeteer.launch({ executablePath, headless: true }); const page = await browser.newPage(); try { await page.setContent(''); await page.evaluate(() => { const builtinId = 'bluesky-dark'; window.chrome = { storage: { local: { get: async () => ({ bskyplusThemes: { schemaVersion: 1, activeId: builtinId, themes: [] } }) }, onChanged: { addListener: () => {} }, } }; }); await page.addScriptTag({ content: model }); await page.addScriptTag({ content: runtime }); await page.waitForFunction(() => document.documentElement.dataset.bskyplusThemeActive === 'bluesky-dark'); assert.equal(await page.$eval('html', element => element.dataset.bskyplusThemeActive), 'bluesky-dark'); assert.equal(await page.evaluate(() => BskyPlusThemes.normalizeLibrary({ schemaVersion: 1, activeId: 'bluesky-dark', themes: [], }).themes.length), 0); } finally { await browser.close(); } }); test('popup theme selection animates palette changes after load and preserves runtime fallbacks', async () => { const browser = await puppeteer.launch({ executablePath, headless: true }); const page = await browser.newPage(); try { await page.goto('https://example.com'); await page.setContent(''); await page.evaluate(() => { window.listeners = []; window.themeRead = new Promise(resolve => { window.resolveThemeRead = resolve; }); window.chrome = { storage: { local: { get: () => window.themeRead }, onChanged: { addListener: fn => window.listeners.push(fn) }, } }; }); await page.addScriptTag({ content: model }); await page.addScriptTag({ content: runtime }); await page.evaluate(() => { window.resolveThemeRead({ bskyplusThemes: { schemaVersion: 1, activeId: 'bluesky-dark', themes: [] } }); return new Promise(resolve => setTimeout(resolve, 0)); }); await page.waitForFunction(() => document.documentElement.dataset.bskyplusThemeActive === 'bluesky-dark'); const hasTransition = () => page.evaluate(() => document.getAnimations().some(animation => animation.effect.pseudoElement?.startsWith('::view-transition'))); assert.equal(await hasTransition(), false, 'initial storage load applies without an animation'); await page.evaluate(() => { const model = window.BskyPlusThemes; const samePalette = { ...structuredClone(model.BUILTIN_THEMES['bluesky-dark']), id: 'same-palette', name: 'Same palette' }; const library = { schemaVersion: 1, activeId: 'bluesky-dark', themes: [samePalette] }; window.library = library; window.listeners.forEach(fn => fn({ bskyplusThemes: { newValue: library } }, 'local')); }); assert.equal(await hasTransition(), false, 'unrelated library edits do not animate'); await page.evaluate(() => { const model = window.BskyPlusThemes; const samePalette = { ...structuredClone(model.BUILTIN_THEMES['bluesky-dark']), id: 'same-palette', name: 'Same palette' }; const library = { schemaVersion: 1, activeId: samePalette.id, themes: [samePalette] }; window.library = library; window.listeners.forEach(fn => fn({ bskyplusThemes: { newValue: library } }, 'local')); }); await page.waitForFunction(() => document.documentElement.dataset.bskyplusThemeActive === 'same-palette'); assert.equal(await hasTransition(), false, 'selecting a theme with the same palette does not animate'); await page.evaluate(() => { const model = window.BskyPlusThemes; const popupTheme = { ...structuredClone(model.BUILTIN_THEMES['bluesky-dark']), id: 'popup-theme', name: 'Popup theme' }; popupTheme.colors.background = '#123456'; const library = { schemaVersion: 1, activeId: popupTheme.id, themes: [popupTheme] }; window.library = library; window.listeners.forEach(fn => fn({ bskyplusThemes: { newValue: library } }, 'local')); }); await page.waitForFunction(() => document.documentElement.dataset.bskyplusThemeActive === 'popup-theme'); await page.waitForFunction(() => document.getAnimations().some(animation => animation.effect.pseudoElement?.startsWith('::view-transition'))); const popupDurations = await page.evaluate(() => [...new Set(document.getAnimations() .filter(animation => animation.effect.pseudoElement === '::view-transition-old(root)' || animation.effect.pseudoElement === '::view-transition-new(root)') .map(animation => animation.effect.getTiming().duration))]); assert.deepEqual(popupDurations, [1000]); await page.waitForFunction(() => !document.getAnimations().some(animation => animation.effect.pseudoElement?.startsWith('::view-transition'))); await page.evaluate(() => { const library = { ...window.library, activeId: null }; window.listeners.forEach(fn => fn({ bskyplusThemes: { newValue: library } }, 'local')); }); await page.waitForFunction(() => !document.documentElement.hasAttribute('data-bskyplus-theme-active')); await page.waitForFunction(() => document.getAnimations().some(animation => animation.effect.pseudoElement?.startsWith('::view-transition'))); await page.waitForFunction(() => !document.getAnimations().some(animation => animation.effect.pseudoElement?.startsWith('::view-transition'))); await page.evaluate(() => { Object.defineProperty(document, 'startViewTransition', { configurable: true, value: undefined }); const popupTheme = { ...structuredClone(window.BskyPlusThemes.BUILTIN_THEMES['bluesky-dark']), id: 'fallback-theme' }; const library = { schemaVersion: 1, activeId: popupTheme.id, themes: [popupTheme] }; window.library = library; window.listeners.forEach(fn => fn({ bskyplusThemes: { newValue: library } }, 'local')); }); await page.waitForFunction(() => document.documentElement.dataset.bskyplusThemeActive === 'fallback-theme'); assert.equal(await hasTransition(), false, 'selection still applies when View Transitions are unavailable'); } finally { await browser.close(); } });