This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318import 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(`<!doctype html><html class="theme--light"><head><style> body { background-color: #fff; color: #000; } .post { background-color: #F9FAFB; border: 1px solid #DCE2EA; color: #000; } .muted { color: #405168; } .primary { background-color: #006AFF; } .primary:hover { background-color: #0059D6; } .white { color: white; } input::placeholder { color: #8798B0; } @media (min-width: 500px) { .media { color: #006AFF; } } .shadow { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); } .composer-edge { border-top: 1px solid #DCE2EA; border-bottom: 1px solid #DCE2EA; } </style></head><body> <article class="post"><span class="muted">handle</span></article> <button class="primary"><span class="white">Follow</span></button> <input placeholder="Search"><span class="media">Link</span> <div id="inline" style="background-color: rgb(239, 242, 246); color: rgb(64, 81, 104)">Text</div> <div id="root" class="post" style="color: #123456; background-color: transparent">Custom text and background</div> <svg><path id="like" fill="#EC4899"></path><path id="repost" stroke="#09B35E"></path></svg> <svg role="img"><circle fill="#006AFF"></circle><path id="verified" fill="#fff"></path></svg> <div id="overlay" style="background-color: rgba(0,0,0,0.6)"></div><div class="shadow"></div> <img id="photo" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"> <div id="art" style="background-image:linear-gradient(#006AFF,#EC4899)"></div> <div id="fade" style="background-image:linear-gradient(to right,#FFFFFF,rgba(255,255,255,0))"></div> <div class="composer-edge" style="border-top-color: transparent; border-bottom-color: transparent">Composer</div> </body></html>`); 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('<!doctype html><html class="theme--light"><head><style>body { background-color: #FFFFFF; }</style></head><body></body></html>'); 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('<!doctype html><html class="theme--light"><head><style>body { background-color: #FFFFFF; }</style></head><body></body></html>'); 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(); }});