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