import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import puppeteer from 'puppeteer-core';
const chromePath = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const modelScript = await readFile('extension/shared/theme-model.js', 'utf8');
const editorScript = await readFile('extension/shared/theme-editor.js', 'utf8');
async function openEditor(browser, { seed = null, rejectSet = false, rejectGet = false, permissionGranted = true } = {}) {
const page = await browser.newPage();
page.setDefaultTimeout(5_000);
await page.setContent('
Existing settings
');
await page.evaluate(({ failSet, failGet, permissionGranted }) => {
let themeNumber = 0;
if (!crypto.randomUUID) Object.defineProperty(crypto, 'randomUUID', {
value: () => `theme-${++themeNumber}`,
});
const listeners = [];
window.__themeStorage = { value: null, sets: [], listeners };
window.__themeMessages = [];
window.__themePermissionRequests = [];
window.__themePermissionOrder = [];
window.postMessage = message => window.__themeMessages.push(structuredClone(message));
window.confirm = () => { throw new Error('Native confirmation should not be used.'); };
window.chrome = {
storage: {
local: {
get: async key => {
if (failGet) throw new Error('storage unavailable');
return { [key]: structuredClone(window.__themeStorage.value) };
},
set: async values => {
if (failSet) throw new Error('storage unavailable');
for (const [key, value] of Object.entries(values)) {
const oldValue = structuredClone(window.__themeStorage.value);
window.__themeStorage.value = structuredClone(value);
window.__themeStorage.sets.push(structuredClone(value));
listeners.forEach(listener => listener({ [key]: { oldValue, newValue: structuredClone(value) } }, 'local'));
}
},
},
onChanged: { addListener: listener => listeners.push(listener) },
},
permissions: {
request: async permissions => {
window.__themePermissionRequests.push(structuredClone(permissions));
window.__themePermissionOrder.push('permission');
return permissionGranted;
},
},
};
URL.createObjectURL = blob => { window.__themeExport = { blob, revoked: false }; return 'blob:bskyplus-theme'; };
URL.revokeObjectURL = () => { window.__themeExport.revoked = true; };
HTMLAnchorElement.prototype.click = function click() {
window.__themeDownload = { href: this.href, download: this.download };
};
}, { failSet: rejectSet, failGet: rejectGet, permissionGranted });
await page.addScriptTag({ content: modelScript });
if (seed) {
await page.evaluate(kind => {
const first = BskyPlusThemes.createTheme('Ocean', 'dark');
const second = BskyPlusThemes.createTheme('Sunrise', 'light');
window.__themeStorage.value = {
schemaVersion: 1,
activeId: kind === 'active' ? first.id : null,
themes: kind === 'one' ? [first] : [first, second],
};
}, seed);
}
await page.addScriptTag({ content: editorScript });
await page.waitForSelector('[data-bskyplus-theme-editor]');
await page.waitForFunction(() => document.querySelector('.bskyplus-theme-name'));
return page;
}
async function setName(page, value) {
await page.$eval('.bskyplus-theme-name', (input, next) => {
input.value = next;
input.dispatchEvent(new Event('input', { bubbles: true }));
}, value);
}
async function stored(page) {
return page.evaluate(() => structuredClone(window.__themeStorage.value));
}
test('theme disclosures animate in both directions, reverse smoothly, and respect reduced motion', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { seed: 'active' });
await page.setViewport({ width: 640, height: 1100 });
for (const file of ['extension/chrome/popup/popup.css', 'extension/shared/settings-frame.css', 'extension/shared/theme-editor.css']) {
await page.addStyleTag({ content: await readFile(file, 'utf8') });
}
await page.$eval('[data-theme-hex="onPrimary"]', input => {
input.value = '#006AFF';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const selectors = ['[data-theme-library]', '[data-theme-contrast-warning]',
...await page.$$eval('[data-theme-group]', groups => groups.map(group => `[data-theme-group="${group.dataset.themeGroup}"]`))];
for (const selector of selectors) {
await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]);
await page.waitForFunction(() => matchMedia('(prefers-reduced-motion: reduce)').matches);
const collapsed = await page.$eval(selector, details => {
if (details.open) details.querySelector('summary').click();
return details.getBoundingClientRect().height;
});
await page.waitForFunction(selector => !document.querySelector(selector).open, {}, selector);
await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'no-preference' }]);
await page.waitForFunction(() => !matchMedia('(prefers-reduced-motion: reduce)').matches);
const middle = await page.$eval(selector, async details => {
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
details.querySelector('summary').click();
await new Promise(resolve => setTimeout(resolve, 90));
return { height: details.getBoundingClientRect().height,
animating: details.querySelector('.bskyplus-theme-section-reveal').getAnimations().length > 0 };
});
await page.waitForFunction(selector => {
const details = document.querySelector(selector);
return details.open && !details.querySelector('.bskyplus-theme-section-reveal').getAnimations().length;
}, {}, selector);
const expanded = await page.$eval(selector, details => details.getBoundingClientRect().height);
assert.ok(middle.animating && middle.height > collapsed && middle.height < expanded, `${selector} expands gradually: ${JSON.stringify({ collapsed, middle, expanded })}`);
const closing = await page.$eval(selector, async details => {
details.querySelector('summary').click();
await new Promise(resolve => setTimeout(resolve, 90));
return { height: details.getBoundingClientRect().height,
inert: details.querySelector('.bskyplus-theme-section-content').inert };
});
assert.ok(closing.inert && closing.height > collapsed && closing.height < expanded, `${selector} collapses gradually and prevents hidden controls receiving focus`);
await page.waitForFunction(selector => !document.querySelector(selector).open, {}, selector);
}
await page.$eval('[data-theme-library]', async details => {
const summary = details.querySelector('summary');
summary.click();
await new Promise(resolve => setTimeout(resolve, 70));
summary.click();
await new Promise(resolve => setTimeout(resolve, 30));
summary.click();
});
await page.waitForFunction(() => {
const details = document.querySelector('[data-theme-library]');
return details.open && details.dataset.themeExpanded === 'true' &&
!details.querySelector('.bskyplus-theme-section-reveal').getAnimations().length;
});
await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]);
for (const selector of selectors) {
assert.equal(await page.$eval(selector, details => {
const expected = !details.open;
details.querySelector('summary').click();
return details.open === expected && !details.querySelector('.bskyplus-theme-section-reveal').getAnimations().length;
}), true, `${selector} toggles immediately with reduced motion`);
}
if (process.env.THEME_SECTIONS_SCREENSHOT) console.log('THEME_SECTIONS_SCREENSHOT=' + await page.screenshot({ encoding: 'base64' }));
} finally { await browser.close(); }
});
test('saved theme gallery shows independent palettes in two columns and preserves collapse state', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { seed: 'active' });
assert.equal(await page.$('.bskyplus-theme-bases'), null);
assert.deepEqual(await page.$$eval('button', buttons => buttons.filter(button => /^New (light|dim|dark)$/.test(button.textContent)).map(button => button.textContent)), []);
for (const file of ['extension/chrome/popup/popup.css', 'extension/shared/settings-frame.css', 'extension/shared/theme-editor.css']) {
await page.addStyleTag({ content: await readFile(file, 'utf8') });
}
const original = await stored(page);
assert.equal(await page.$('.bskyplus-theme-select'), null);
assert.equal(await page.$('[data-theme-preview]'), null);
assert.equal(await page.$eval('[data-theme-library]', node =>
node.previousElementSibling.classList.contains('bskyplus-theme-preview-toggle')), true);
const includedCount = await page.evaluate(() => Object.keys(BskyPlusThemes.BUILTIN_THEMES).length);
assert.equal(await page.$$eval('[data-theme-card]', cards => cards.length), includedCount + original.themes.length);
for (const theme of original.themes) {
assert.equal(await page.$eval(`[data-theme-card="${theme.id}"] .bskyplus-theme-preview`, card =>
card.style.getPropertyValue('--bskyplus-preview-background')), theme.colors.background);
}
for (const width of [640, 390]) {
await page.setViewport({ width, height: 900 });
const cards = await page.$$eval('[data-theme-card]', cards => cards.map(card => {
const { x, y, width } = card.getBoundingClientRect();
return { x, y, width };
}));
assert.equal(cards[0].width, cards[1].width);
assert.equal(cards[0].y, cards[1].y);
assert.ok(cards[1].x > cards[0].x + cards[0].width);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
}
await page.click(`[data-theme-card="${original.themes[1].id}"] .bskyplus-theme-preview-post`);
assert.equal(await page.$eval('.bskyplus-theme-name', input => input.value), 'Sunrise');
assert.equal(await page.$eval('[data-theme-selected="true"]', card => card.dataset.themeCard), original.themes[1].id);
assert.equal(await page.$eval('[data-theme-selected="true"] .bskyplus-theme-card-preview', preview =>
getComputedStyle(preview).outlineWidth), '2px');
assert.deepEqual(await stored(page), original);
await page.click(`[data-theme-select="${original.themes[0].id}"]`);
await page.$eval('[data-theme-hue]', input => {
input.value = '60'; input.dispatchEvent(new Event('input', { bubbles: true }));
});
assert.equal(await page.$eval(`[data-theme-card="${original.activeId}"] .bskyplus-theme-preview`, card =>
card.style.getPropertyValue('--bskyplus-preview-primary')), original.themes[0].colors.primary);
await page.click('.bskyplus-theme-hue button');
await page.click('[data-theme-library] > summary');
await setName(page, 'Ocean edited');
await page.click('[data-theme-save]');
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent === 'Saved and applied');
assert.equal(await page.$eval('[data-theme-library]', details => details.open), false);
await page.click('[data-theme-library] > summary');
assert.equal(await page.$eval(`[data-theme-card="${original.activeId}"] .bskyplus-theme-card-name`, node => node.textContent), 'Ocean edited');
} finally { await browser.close(); }
});
test('hue shift updates the complete draft, resets losslessly, and saves only on request', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { seed: 'active' });
const original = await stored(page);
const readColors = () => page.$$eval('[data-theme-hex]', inputs => Object.fromEntries(
inputs.map(input => [input.dataset.themeHex, input.value.toUpperCase()])));
const shift = value => page.$eval('[data-theme-hue]', (input, value) => {
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}, value);
assert.equal(await page.$eval('.bskyplus-theme-preview-toggle input', input => input.checked), true);
await shift(120);
const shifted = await readColors();
assert.notEqual(shifted.primary, original.themes[0].colors.primary);
assert.deepEqual(shifted, await page.evaluate(colors => BskyPlusThemes.shiftHue(colors, 120), original.themes[0].colors));
assert.deepEqual(await stored(page), original);
assert.deepEqual(await page.evaluate(() => window.__themeMessages.at(-1).theme.colors), shifted);
await shift(-47);
await page.click('.bskyplus-theme-hue button');
assert.deepEqual(await readColors(), original.themes[0].colors);
await shift(73);
await page.$eval('[data-theme-hex="background"]', input => {
input.value = '#804040';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
assert.equal(await page.$eval('[data-theme-hue]', input => input.value), '0');
await shift(120);
assert.equal((await readColors()).background, '#408040');
const finalColors = await readColors();
await page.click('.bskyplus-theme-apply');
if (await page.$('dialog[open]')) await page.click('dialog button[type="submit"]');
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent === 'Saved and applied');
assert.deepEqual((await stored(page)).themes.find(theme => theme.id === original.activeId).colors, finalColors);
assert.equal(await page.$eval('[data-theme-hue]', input => input.value), '0');
await page.click(`[data-theme-select="${original.themes[1].id}"]`);
assert.deepEqual(await readColors(), original.themes[1].colors);
assert.equal(await page.$eval('[data-theme-hue-value]', output => output.textContent), '0°');
} finally {
await browser.close();
}
});
test('color picker coalesces rapid drag input before a change or save', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { seed: 'active' });
const original = await stored(page);
const choose = (value, type) => page.$eval('[data-theme-picker="primary"]', (picker, { value, type }) => {
picker.value = value;
picker.dispatchEvent(new Event(type, { bubbles: true }));
}, { value, type });
await choose('#123456', 'input');
await page.waitForFunction(() => window.__themeMessages.at(-1)?.theme?.colors.primary === '#123456');
assert.equal(await page.$eval('[data-theme-hex="primary"]', input => input.value), '#123456');
assert.equal(await page.$eval('[data-theme-save-as]', button => button.disabled), false);
assert.deepEqual(await stored(page), original);
await choose('#654321', 'input');
await page.waitForFunction(() => window.__themeMessages.at(-1)?.theme?.colors.primary === '#654321');
assert.deepEqual(await stored(page), original);
const messageCount = await page.evaluate(() => window.__themeMessages.length);
await page.$eval('[data-theme-picker="primary"]', picker => {
for (const color of ['#112233', '#223344', '#334455']) {
picker.value = color;
picker.dispatchEvent(new Event('input', { bubbles: true }));
}
});
assert.equal(await page.evaluate(() => window.__themeMessages.length), messageCount);
await page.waitForFunction(() => window.__themeMessages.at(-1)?.theme?.colors.primary === '#334455');
assert.equal(await page.evaluate(() => window.__themeMessages.length), messageCount + 1);
assert.deepEqual(await stored(page), original);
await choose('#AABBCC', 'change');
assert.equal(await page.evaluate(() => window.__themeMessages.at(-1).theme.colors.primary), '#AABBCC');
assert.deepEqual(await stored(page), original);
} finally { await browser.close(); }
});
test('low contrast warns before saving and allows cancelling or saving anyway', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
await page.$eval('[data-theme-hex="surfaceRaised"]', input => {
input.value = '#FFFFFF';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
assert.equal(await page.$eval('[data-theme-contrast-warning]', node => node.hidden), false);
assert.match(await page.$eval('[data-theme-contrast-warning]', node => node.textContent), /Main text on Raised surface: 1.0:1/);
await page.click('[data-theme-save]');
await page.waitForSelector('dialog[open]');
assert.equal(await stored(page), null);
await page.keyboard.press('Escape');
await page.waitForSelector('dialog', { hidden: true });
assert.equal(await stored(page), null);
await page.click('[data-theme-save]');
assert.equal(await page.$eval('dialog button[type="submit"]', button => button.textContent), 'Save anyway');
await page.click('dialog button[type="submit"]');
await page.waitForFunction(() => window.__themeStorage.value?.themes.length === 1);
assert.equal((await stored(page)).themes[0].colors.surfaceRaised, '#FFFFFF');
assert.equal((await stored(page)).activeId, (await stored(page)).themes[0].id);
} finally { await browser.close(); }
});
test('Save as requires edits and creates a named copy without changing the original', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { seed: 'active' });
const original = await stored(page);
assert.equal(await page.$eval('[data-theme-save-as]', button => button.disabled), true);
await page.$eval('[data-theme-hex="background"]', input => {
input.value = '#10231E';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
assert.equal(await page.$eval('[data-theme-save-as]', button => button.disabled), false);
await page.click('[data-theme-save-as]');
await page.waitForSelector('dialog[open] #bskyplusThemeCopyName');
await page.keyboard.press('Escape');
await page.waitForSelector('dialog', { hidden: true });
assert.deepEqual(await stored(page), original);
await page.click('[data-theme-save-as]');
await page.$eval('#bskyplusThemeCopyName', input => { input.value = 'Forest copy'; });
await page.click('dialog button[type="submit"]');
await page.waitForFunction(() => window.__themeStorage.value?.themes.length === 3);
const saved = await stored(page);
assert.deepEqual(saved.themes.find(theme => theme.id === original.activeId), original.themes[0]);
const copy = saved.themes.find(theme => theme.id === saved.activeId);
assert.equal(copy.name, 'Forest copy');
assert.equal(copy.colors.background, '#10231E');
assert.notEqual(copy.id, original.activeId);
assert.equal(await page.$eval('[data-theme-save-as]', button => button.disabled), true);
} finally { await browser.close(); }
});
test('contrast recommendations reveal both settings and clear both highlights when either color fixes the pair', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
await page.$eval('[data-theme-hex="onPrimary"]', input => {
input.value = '#006AFF';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.click('[data-theme-contrast-warning] summary');
assert.match(await page.$eval('[data-theme-contrast-warning]', node => node.textContent), /Darken “Text on colored controls” in Links and primary controls/);
await page.click('[data-theme-contrast-warning] [data-theme-contrast-edit="onPrimary"]');
await page.waitForFunction(() => document.activeElement?.dataset.themeHex === 'onPrimary');
assert.equal(await page.$eval('[data-theme-field="onPrimary"]', field =>
field.classList.contains('bskyplus-theme-field-highlight') && field.closest('details').open), true);
assert.deepEqual(await page.$$eval('.bskyplus-theme-field-highlight', fields => fields.map(field => field.dataset.themeField).sort()), ['onPrimary', 'primary']);
await page.$eval('[data-theme-field="onPrimary"]', field => { field.closest('details').open = false; });
await page.click('[data-theme-save]');
await page.click('dialog [data-theme-contrast-edit="onPrimary"]');
await page.waitForSelector('dialog', { hidden: true });
await page.waitForFunction(() => document.activeElement?.dataset.themeHex === 'onPrimary');
assert.equal(await stored(page), null);
assert.equal(await page.$$eval('.bskyplus-theme-field-highlight', fields => fields.length), 2);
await page.$eval('[data-theme-hex="onPrimary"]', input => {
input.value = '#000000';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
assert.equal(await page.$('.bskyplus-theme-field-highlight'), null);
assert.equal(await page.$eval('[data-theme-contrast-warning]', node => node.hidden), true);
await page.$eval('[data-theme-hex="surfaceRaised"]', input => {
input.value = '#FFFFFF';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.$$eval('[data-theme-group]', groups => groups.forEach(group => { group.open = false; }));
await page.click('[data-theme-contrast-warning] [data-theme-contrast-edit="text"][data-theme-contrast-background="surfaceRaised"]');
await page.waitForFunction(() => document.activeElement?.dataset.themeHex === 'text');
assert.deepEqual(await page.$$eval('.bskyplus-theme-field-highlight', fields => fields.map(field => ({ key: field.dataset.themeField, open: field.closest('details').open }))), [
{ key: 'surfaceRaised', open: true }, { key: 'text', open: true },
]);
await page.$eval('[data-theme-hex="surfaceRaised"]', input => {
input.value = '#19222E';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
assert.equal(await page.$('.bskyplus-theme-field-highlight'), null);
} finally { await browser.close(); }
});
test('contrast highlights expire after three seconds without changing the colors or closing sections', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
await page.$eval('[data-theme-hex="onPrimary"]', input => {
input.value = '#006AFF';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.click('[data-theme-contrast-warning] summary');
await page.click('[data-theme-contrast-edit="onPrimary"]');
assert.equal(await page.$$eval('.bskyplus-theme-field-highlight', fields => fields.length), 2);
await page.waitForFunction(() => !document.querySelector('.bskyplus-theme-field-highlight'), { timeout: 4500 });
assert.equal(await page.$eval('[data-theme-hex="onPrimary"]', input => input.value), '#006AFF');
assert.equal(await page.$eval('[data-theme-field="onPrimary"]', field => field.closest('details').open), true);
} finally { await browser.close(); }
});
test('saving and applying preserves open and closed theme groups', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
await setName(page, 'Keep editing');
assert.equal(await page.$eval('[data-theme-save]', button => button.textContent), 'Save and apply');
const states = [false, true, false, true, true, false];
await page.$$eval('[data-theme-group]', (groups, states) => {
groups.forEach((group, index) => { group.open = states[index]; });
}, states);
await page.click('.bskyplus-theme-apply');
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent === 'Saved and applied');
assert.equal(await page.$$eval('.bskyplus-theme-actions button', buttons =>
buttons.filter(button => button.textContent === 'Apply').length), 1);
assert.equal(await page.$eval('[data-theme-save-as]', button => button.disabled), true);
assert.deepEqual(await page.$$eval('[data-theme-group]', groups => groups.map(group => group.open)), states);
await setName(page, 'Keep editing again');
assert.equal(await page.$eval('[data-theme-save]', button => button.textContent), 'Save and apply');
await page.click('.bskyplus-theme-apply');
await page.waitForFunction(() => window.__themeStorage.value?.themes[0].name === 'Keep editing again');
assert.deepEqual(await page.$$eval('[data-theme-group]', groups => groups.map(group => group.open)), states);
} finally { await browser.close(); }
});
test('saves multiple local themes and reloads the active library', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
await setName(page, 'Ocean');
await page.click('[data-theme-save]');
await page.waitForFunction(() => window.__themeStorage.value?.themes.length === 1);
await page.click('[data-theme-select="bluesky-light"]');
await setName(page, 'Sunrise');
await page.click('.bskyplus-theme-apply');
await page.click('dialog button[type="submit"]');
await page.waitForFunction(() => window.__themeStorage.value?.themes.length === 2);
const library = await stored(page);
assert.deepEqual(library.themes.map(theme => theme.name), ['Ocean', 'Sunrise']);
assert.equal(library.activeId, library.themes[1].id);
const reload = await browser.newPage();
await reload.setContent('');
await reload.evaluate(value => {
const listeners = [];
window.chrome = { storage: { local: { get: async key => ({ [key]: value }), set: async () => {} }, onChanged: { addListener: listener => listeners.push(listener) } } };
}, library);
await reload.addScriptTag({ content: modelScript });
await reload.addScriptTag({ content: editorScript });
await reload.waitForFunction(savedCount => document.querySelectorAll('[data-theme-card]').length ===
Object.keys(BskyPlusThemes.BUILTIN_THEMES).length + savedCount, {}, library.themes.length);
assert.equal(await reload.$eval('.bskyplus-theme-name', input => input.value), 'Sunrise');
} finally {
await browser.close();
}
});
test('apply, Bluesky default, and delete change only the active saved theme', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { seed: 'active' });
const initial = await stored(page);
await page.click('.bskyplus-theme-apply');
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent === 'Saved and applied');
assert.equal((await stored(page)).activeId, initial.themes[0].id);
await page.click('[data-theme-default]');
await page.waitForFunction(() => window.__themeStorage.value.activeId === null);
assert.equal((await stored(page)).themes.length, 2);
await page.click('.bskyplus-theme-delete');
await page.waitForSelector('[data-theme-confirm][open]');
await page.click('[data-theme-confirm-accept]');
await page.waitForFunction(() => window.__themeStorage.value.themes.length === 1);
const afterDelete = await stored(page);
assert.equal(afterDelete.themes[0].id, initial.themes[1].id);
assert.equal(afterDelete.activeId, null);
} finally {
await browser.close();
}
});
test('invalid hex stays out of storage and a valid draft exports JSON', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
await page.$eval('[data-theme-hex="background"]', input => {
input.value = '#bad';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
assert.equal(await page.$eval('[data-theme-hex="background"]', input => input.getAttribute('aria-invalid')), 'true');
assert.equal(await stored(page), null);
await setName(page, 'Export me');
await page.click('.bskyplus-theme-actions button:last-child');
await page.waitForFunction(() => !!window.__themeExport && !!window.__themeDownload);
const exported = await page.evaluate(async () => ({
json: await window.__themeExport.blob.text(),
download: window.__themeDownload.download,
}));
const theme = JSON.parse(exported.json);
assert.equal(theme.name, 'Export me');
assert.equal(theme.schemaVersion, 1);
assert.equal(exported.download, 'Export-me.json');
} finally {
await browser.close();
}
});
test('imports a valid JSON theme as an unsaved draft', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
const imported = await page.evaluate(() => ({
...BskyPlusThemes.createTheme('Imported ocean', 'light'),
id: 'imported-ocean',
colors: { ...BskyPlusThemes.PRESETS.light, primary: '#123456' },
}));
await page.$eval('[data-theme-import-input]', (input, theme) => {
const file = new File([JSON.stringify(theme)], 'ocean.json', { type: 'application/json' });
Object.defineProperty(input, 'files', { configurable: true, value: [file] });
input.dispatchEvent(new Event('change', { bubbles: true }));
}, imported);
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent.startsWith('Imported theme'));
assert.equal(await page.$eval('.bskyplus-theme-name', input => input.value), 'Imported ocean');
assert.equal(await page.$eval('[data-theme-hex="primary"]', input => input.value), '#123456');
assert.equal(await page.$('[data-theme-select][aria-pressed="true"]'), null);
assert.equal(await stored(page), null);
await page.click('[data-theme-save]');
await page.click('dialog button[type="submit"]');
await page.waitForFunction(() => window.__themeStorage.value?.themes[0]?.name === 'Imported ocean');
assert.equal((await stored(page)).themes[0].id, 'imported-ocean');
} finally {
await browser.close();
}
});
test('rejects invalid JSON without replacing the current draft', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
await setName(page, 'Keep this draft');
await page.$eval('[data-theme-import-input]', input => {
const file = new File(['{not JSON'], 'broken.json', { type: 'application/json' });
Object.defineProperty(input, 'files', { configurable: true, value: [file] });
input.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent === 'The selected file is not valid JSON.');
assert.equal(await page.$eval('.bskyplus-theme-name', input => input.value), 'Keep this draft');
assert.equal(await stored(page), null);
} finally {
await browser.close();
}
});
test('imports a theme with an existing ID as a copy', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { seed: 'one' });
const original = (await stored(page)).themes[0];
await page.$eval('[data-theme-import-input]', (input, theme) => {
const file = new File([JSON.stringify(theme)], 'copy.json', { type: 'application/json' });
Object.defineProperty(input, 'files', { configurable: true, value: [file] });
input.dispatchEvent(new Event('change', { bubbles: true }));
}, { ...original, name: 'Ocean copy' });
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent.startsWith('Imported as a copy'));
await page.click('[data-theme-save]');
await page.waitForFunction(() => window.__themeStorage.value?.themes.length === 2);
const library = await stored(page);
assert.equal(library.themes[0].id, original.id);
assert.notEqual(library.themes[1].id, original.id);
assert.equal(library.themes[1].name, 'Ocean copy');
} finally {
await browser.close();
}
});
test('imports a WitchSky base palette and its variants as separate saved themes', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
await page.evaluate(() => {
const colors = {
canvas: '#1c1310', surface: '#2e2018', surfaceRaised: '#251a15', text: '#f4deb8',
textMuted: '#e8c28a', border: '#3d2b1f', accent: '#d4975c', accentSoft: '#4d3828',
onAccent: '#2c221a', positive: '#4caf7d', warning: '#e2b76f', critical: '#e06c6c', favorite: '#cc5577',
};
const uri = 'at://did:plc:l37td5yhxl2irrzrgvei4qay/app.witchsky.theme.colors/3mvpzxkrbms25';
window.fetch = async url => {
if (String(url).startsWith('https://plc.directory/')) {
return new Response(JSON.stringify({ service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.test' }] }));
}
return new Response(JSON.stringify({ uri, value: {
$type: 'app.witchsky.theme.colors', name: 'Coffee', base: { name: 'Default', colors },
variants: [
{ name: 'Light', colors: { canvas: '#f7f2e9', surface: '#efe3d0', surfaceRaised: '#fdf8f0', text: '#1c1310' } },
{ name: 'Dark', colors: { canvas: '#050505', surface: '#171717', surfaceRaised: '#111111', text: '#f7f1e8' } },
],
} }));
};
});
await page.type('[data-theme-witchsky-uri]', 'at://did:plc:l37td5yhxl2irrzrgvei4qay/app.witchsky.theme.colors/3mvpzxkrbms25');
await page.click('[data-theme-witchsky-import]');
await page.waitForFunction(() => window.__themeStorage.value?.themes.length === 3);
const library = await stored(page);
assert.deepEqual(library.themes.map(theme => theme.name), ['Coffee — Default', 'Coffee — Light', 'Coffee — Dark']);
assert.equal(library.activeId, null);
assert.equal(await page.$eval('.bskyplus-theme-name', input => input.value), 'Coffee — Default');
assert.equal(await page.$eval('[data-theme-status]', node => node.textContent), 'Imported 3 WitchSky themes');
} finally {
await browser.close();
}
});
test('imports the provided did:web WitchSky URI after verifying its DID document and PDS record', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
await page.evaluate(() => {
const uri = 'at://did:web:vessel.darkworld.download/app.witchsky.theme.colors/3mv3ih7bfgs2m';
const colors = {
canvas: '#1c1310', surface: '#2e2018', surfaceRaised: '#251a15', text: '#f4deb8',
textMuted: '#e8c28a', border: '#3d2b1f', accent: '#d4975c', accentSoft: '#4d3828',
onAccent: '#2c221a', positive: '#4caf7d', warning: '#e2b76f', critical: '#e06c6c', favorite: '#cc5577',
};
const variants = Array.from({ length: 11 }, (_, index) => ({
name: 'Variant ' + (index + 1),
colors: { canvas: index % 2 ? '#f7f2e9' : '#050505' },
}));
const didDocument = {
id: 'did:web:vessel.darkworld.download',
service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pushingbuddies.kralsei.stream' }],
};
const record = { uri, value: {
$type: 'app.witchsky.theme.colors', name: 'Vessel palette',
base: { name: 'Default', colors }, variants,
} };
window.__witchSkyRequests = [];
window.fetch = async url => {
const requestUrl = new URL(url);
window.__themePermissionOrder.push('fetch ' + requestUrl.pathname);
window.__witchSkyRequests.push(requestUrl.href);
if (requestUrl.href === 'https://vessel.darkworld.download/.well-known/did.json') {
return new Response(JSON.stringify(didDocument));
}
if (requestUrl.origin === 'https://pushingbuddies.kralsei.stream') {
window.__witchSkyRecordQuery = Object.fromEntries(requestUrl.searchParams.entries());
return new Response(JSON.stringify(record));
}
throw new Error('Unexpected WitchSky request: ' + requestUrl.href);
};
});
const uri = 'at://did:web:vessel.darkworld.download/app.witchsky.theme.colors/3mv3ih7bfgs2m';
await page.type('[data-theme-witchsky-uri]', uri);
await page.click('[data-theme-witchsky-import]');
await page.waitForFunction(() => window.__themeStorage.value?.themes.length === 12);
const library = await stored(page);
assert.equal(library.themes.length, 12);
assert.equal(library.themes[0].name, 'Vessel palette — Default');
assert.equal(library.themes.at(-1).name, 'Vessel palette — Variant 11');
assert.deepEqual(await page.evaluate(() => window.__themePermissionRequests), [
{ origins: ['https://vessel.darkworld.download/*'] },
]);
assert.deepEqual(await page.evaluate(() => window.__themePermissionOrder), [
'permission', 'fetch /.well-known/did.json', 'fetch /xrpc/com.atproto.repo.getRecord',
]);
assert.deepEqual(await page.evaluate(() => window.__themeStorage.value && window.__witchSkyRecordQuery), {
repo: 'did:web:vessel.darkworld.download',
collection: 'app.witchsky.theme.colors',
rkey: '3mv3ih7bfgs2m',
});
assert.equal(await page.$eval('[data-theme-status]', node => node.textContent), 'Imported 12 WitchSky themes');
} finally {
await browser.close();
}
});
test('denied did:web host permission prevents network access and storage changes', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { permissionGranted: false });
await page.evaluate(() => {
window.__witchSkyRequests = [];
window.fetch = async url => { window.__witchSkyRequests.push(String(url)); return new Response('{}'); };
});
await page.type('[data-theme-witchsky-uri]', 'at://did:web:vessel.darkworld.download/app.witchsky.theme.colors/3mv3ih7bfgs2m');
await page.click('[data-theme-witchsky-import]');
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent === 'Access to the did:web repository was not granted.');
assert.deepEqual(await page.evaluate(() => window.__themePermissionRequests), [
{ origins: ['https://vessel.darkworld.download/*'] },
]);
assert.deepEqual(await page.evaluate(() => window.__witchSkyRequests), []);
assert.equal(await stored(page), null);
} finally {
await browser.close();
}
});
test('did:web importer rejects a DID document whose id does not match the URI', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
await page.evaluate(() => {
window.__witchSkyRequests = [];
window.fetch = async url => {
const requestUrl = new URL(url);
window.__witchSkyRequests.push(requestUrl.href);
return new Response(JSON.stringify({
id: 'did:web:another.example',
service: [{ id: '#atproto_pds', serviceEndpoint: 'https://pds.example.test' }],
}));
};
});
await page.type('[data-theme-witchsky-uri]', 'at://did:web:vessel.darkworld.download/app.witchsky.theme.colors/3mv3ih7bfgs2m');
await page.click('[data-theme-witchsky-import]');
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent === 'The did:web document does not match the WitchSky repository.');
assert.deepEqual(await page.evaluate(() => window.__witchSkyRequests), [
'https://vessel.darkworld.download/.well-known/did.json',
]);
assert.equal(await stored(page), null);
} finally {
await browser.close();
}
});
test('page preview checkbox sends validated drafts without saving them', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
assert.equal(await page.$eval('.bskyplus-theme-preview-toggle input', input => input.checked), true);
await page.click('[data-theme-select="bluesky-dark"]');
await setName(page, 'Live preview');
const preview = await page.evaluate(() => window.__themeMessages.filter(message => message.type === 'BSKYPLUS_THEME_PREVIEW').at(-1));
assert.equal(preview.theme.name, 'Live preview');
assert.equal(await stored(page), null);
await page.click('.bskyplus-theme-preview-toggle input');
const cleared = await page.evaluate(() => window.__themeMessages.filter(message => message.type === 'BSKYPLUS_THEME_PREVIEW').at(-1));
assert.equal(cleared.theme, null);
} finally {
await browser.close();
}
});
test('native theme preview stays off on initial load until a theme is chosen', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
for (const seed of [null, 'two']) {
const page = await openEditor(browser, { seed });
const initialPreviews = await page.evaluate(() => window.__themeMessages
.filter(message => message.type === 'BSKYPLUS_THEME_PREVIEW'));
assert.ok(initialPreviews.length > 0);
assert.ok(initialPreviews.every(message => message.theme === null), `seed ${seed}: initial preview messages should clear rather than choose a theme`);
await page.click('[data-theme-select="bluesky-light"]');
await page.waitForFunction(() => window.__themeMessages
.filter(message => message.type === 'BSKYPLUS_THEME_PREVIEW').at(-1)?.theme?.id === 'bluesky-light');
const chosen = await page.evaluate(() => window.__themeMessages
.filter(message => message.type === 'BSKYPLUS_THEME_PREVIEW').at(-1));
assert.equal(chosen.theme.id, 'bluesky-light');
await page.close();
}
} finally {
await browser.close();
}
});
test('dirty edits survive a cancelled selection and an external library change', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { seed: 'two' });
await setName(page, 'Unsaved Ocean');
await page.click(`[data-theme-select="${(await stored(page)).themes[1].id}"]`);
await page.waitForSelector('[data-theme-confirm][open]');
assert.match(await page.$eval('[data-theme-confirm]', dialog => dialog.textContent), /Discard unsaved theme changes\?/);
await page.click('[data-theme-confirm-cancel]');
await page.waitForSelector('[data-theme-confirm]', { hidden: true });
assert.equal(await page.$eval('.bskyplus-theme-name', input => input.value), 'Unsaved Ocean');
assert.equal(await page.$eval('[data-theme-select][aria-pressed="true"]', button => button.dataset.themeSelect), (await stored(page)).themes[0].id);
await page.evaluate(() => {
const changed = BskyPlusThemes.createTheme('Elsewhere', 'dim');
window.__themeStorage.listeners.forEach(listener => listener({
bskyplusThemes: { oldValue: null, newValue: { schemaVersion: 1, activeId: changed.id, themes: [changed] } },
}, 'local'));
});
assert.equal(await page.$eval('.bskyplus-theme-name', input => input.value), 'Unsaved Ocean');
assert.match(await page.$eval('[data-theme-status]', node => node.textContent), /another Bsky\+ window/);
} finally {
await browser.close();
}
});
test('discard modal confirms theme replacement and delete modal stays in the page', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { seed: 'active' });
const original = await stored(page);
await setName(page, 'Unsaved Ocean');
await page.click('[data-theme-select="bluesky-dim"]');
await page.waitForSelector('[data-theme-confirm][open]');
await page.keyboard.press('Escape');
await page.waitForSelector('[data-theme-confirm]', { hidden: true });
assert.equal(await page.$eval('.bskyplus-theme-name', input => input.value), 'Unsaved Ocean');
await page.click(`[data-theme-select="${original.themes[1].id}"]`);
await page.waitForSelector('[data-theme-confirm][open]');
await page.click('[data-theme-confirm-accept]');
await page.waitForFunction(() => document.querySelector('.bskyplus-theme-name')?.value === 'Sunrise');
assert.equal(await page.$eval('.bskyplus-theme-name', input => input.value), 'Sunrise');
assert.deepEqual(await stored(page), original);
await page.click('.bskyplus-theme-delete');
await page.waitForSelector('[data-theme-confirm][open]');
assert.match(await page.$eval('[data-theme-confirm]', dialog => dialog.textContent), /Delete “Sunrise”\?/);
await page.click('[data-theme-confirm-cancel]');
assert.deepEqual(await stored(page), original);
await page.click('.bskyplus-theme-delete');
await page.waitForSelector('[data-theme-confirm][open]');
await page.click('[data-theme-confirm-accept]');
await page.waitForFunction(() => window.__themeStorage.value?.themes.length === 1);
assert.equal((await stored(page)).themes[0].name, 'Ocean');
} finally { await browser.close(); }
});
test('built-in themes apply without saving, edits save as custom copies, and saved themes remain deletable', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { seed: 'one' });
const before = await stored(page);
const includedCount = await page.evaluate(() => Object.keys(BskyPlusThemes.BUILTIN_THEMES).length);
assert.equal(await page.$$eval('[data-theme-card]', cards => cards.length), includedCount + before.themes.length);
const darkId = 'bluesky-dark';
await page.click(`[data-theme-select="${darkId}"]`);
assert.equal(await page.$eval('.bskyplus-theme-delete', button => button.disabled), false);
await page.click('[data-theme-save]');
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent === 'Saved and applied');
const applied = await stored(page);
assert.equal(applied.activeId, darkId);
assert.deepEqual(applied.themes, before.themes);
await setName(page, 'My Dark Copy');
await page.click('[data-theme-save]');
if (await page.$('dialog[open]')) await page.click('dialog button[type="submit"]');
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent === 'Saved and applied');
const copied = await stored(page);
assert.equal(copied.themes.length, before.themes.length + 1);
assert.notEqual(copied.activeId, darkId);
assert.equal(copied.themes.at(-1).name, 'My Dark Copy');
await page.click('.bskyplus-theme-delete');
await page.click('dialog [data-theme-confirm-accept]');
await page.waitForFunction(id => !document.querySelector(`[data-theme-card="${id}"]`), {}, copied.activeId);
assert.equal((await stored(page)).activeId, null);
assert.equal(await page.$$eval('[data-theme-card]', cards => cards.filter(card => card.dataset.themeCard.startsWith('bluesky-')).length), 3);
} finally { await browser.close(); }
});
test('all included themes can be removed per user and stay removed after storage refresh', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser);
const builtinIds = await page.evaluate(() => Object.keys(BskyPlusThemes.BUILTIN_THEMES));
for (const id of builtinIds) {
await page.click(`[data-theme-select="${id}"]`);
assert.equal(await page.$eval('.bskyplus-theme-delete', button => button.disabled), false, `${id} has a delete action`);
await page.click('[data-theme-save]');
await page.waitForFunction(id => window.__themeStorage.value?.activeId === id, {}, id);
await page.click('.bskyplus-theme-delete');
await page.waitForSelector('[data-theme-confirm][open]');
await page.click('[data-theme-confirm-accept]');
await page.waitForFunction(id => !document.querySelector(`[data-theme-card="${id}"]`) &&
window.__themeStorage.value?.deletedBuiltinIds?.includes(id) &&
window.__themeStorage.value.activeId === null, {}, id);
const latestPreview = await page.evaluate(() => window.__themeMessages
.filter(message => message.type === 'BSKYPLUS_THEME_PREVIEW').at(-1));
assert.equal(latestPreview.theme, null, `${id} deletion clears the page preview`);
}
const library = await stored(page);
assert.deepEqual(library.deletedBuiltinIds, builtinIds);
assert.equal(library.themes.length, 0);
assert.equal(library.activeId, null);
assert.deepEqual(await page.$$eval('[data-theme-card]', cards => cards.map(card => card.dataset.themeCard)), []);
assert.deepEqual(await page.evaluate(() => BskyPlusThemes.availableBuiltinThemes(window.__themeStorage.value)), []);
} finally { await browser.close(); }
});
test('a storage write failure leaves the draft dirty and reports the failure', async () => {
const browser = await puppeteer.launch({ executablePath: chromePath, headless: true });
try {
const page = await openEditor(browser, { rejectSet: true });
await setName(page, 'Cannot save');
await page.click('[data-theme-save]');
await page.waitForFunction(() => document.querySelector('[data-theme-status]').textContent.includes('Could not save'));
assert.equal(await stored(page), null);
assert.equal(await page.$eval('[data-theme-save]', button => button.disabled), false);
} finally {
await browser.close();
}
});