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(); }
});