// Checks the redmine_tonalmine plugin: the admin default appearance (Display settings tab), users' // choices saved on their account, Reset to default, the one-time upload of a localStorage choice, // and the endpoint's validation. Needs the plugin mounted (dev/docker-compose.yml) and the seed users. // Usage: node check-appearance.mjs (exits 1 on any failure; leaves the admin default empty) import { chromium } from 'playwright'; const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070'; const browser = await chromium.launch(); let failures = 0; function check(name, ok, detail = '') { if (!ok) failures++; console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : `\n ${detail}`}`); } async function login(user, password = 'password123', setup) { const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await context.newPage(); await page.goto(`${BASE}/login`); if (setup) await page.evaluate(setup); await page.fill('#username', user); await page.fill('#password', password); await page.click('#login-submit'); await page.waitForURL((url) => !url.pathname.startsWith('/login')); await page.waitForLoadState('networkidle'); return page; } // The attributes theme.js sets; absent means the built-in default (auto, standard, normal, purple). const looks = (page) => page.evaluate(() => { const d = document.documentElement.dataset; return `${d.mode ?? 'auto'} ${d.contrast ?? 'standard'} ${d.size ?? 'normal'} ${d.palette ?? 'purple'}`; }); async function expectLook(name, page, want) { const got = await looks(page); check(name, got === want, `expected "${want}", got "${got}"`); } const put = (page, body, withToken = true) => page.evaluate(async ([body, withToken]) => { const headers = { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }; if (withToken) headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content; const response = await fetch('/tonalmine/appearance', { method: 'PUT', headers, body: JSON.stringify(body) }); return response.status; }, [body, withToken]); // Clicks a picker control in the profile menu and waits until the account has it. async function pick(page, selector) { const saved = page.waitForResponse((r) => r.url().includes('/tonalmine/appearance')); await page.evaluate((s) => document.querySelector(`#account ${s}`).click(), selector); return (await saved).status(); } // Picks a custom color the way the color dialog does (input while dragging, change at the end). const pickColor = (page, scope, seed) => page.evaluate(([scope, seed]) => { const input = document.querySelector(`${scope} [data-nm-custom]`); input.value = seed; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); }, [scope, seed]); const customCss = (page) => page.evaluate(() => document.getElementById('nm-custom-palette')?.textContent.length ?? 0); async function saveDefault(page, change) { await page.goto(`${BASE}/settings?tab=display`); await change(); await Promise.all([page.waitForURL(/tab=display/), page.click('#tab-content-display form [type=submit]')]); await page.waitForLoadState('networkidle'); } const resetUser = async (user) => { const page = await login(user); await put(page, {}); await page.context().close(); }; await Promise.all(['jdoe', 'msmith', 'pweber'].map(resetUser)); // 1. Admin default on the Display settings tab const admin = await login('admin', 'admin'); await admin.goto(`${BASE}/settings?tab=display`); // Mode, contrast, size and color are four form rows const rowsShown = () => admin.$$eval('p:has([data-nm-default])', (rows) => rows.length === 4 && rows.map((r) => !!r.offsetParent)); check('default picker shown while Tonalmine is selected', (await rowsShown())?.every(Boolean)); await admin.selectOption('#settings_ui_theme', ''); check('default picker hidden and not posted for another theme', (await rowsShown())?.every((shown) => !shown) && (await admin.$$eval('[data-nm-default-input]', (inputs) => inputs.every((i) => i.disabled)))); await saveDefault(admin, async () => { await admin.click('[data-nm-default] [data-nm-palette="blue"]'); await admin.click('[data-nm-default] [data-nm-mode="dark"]'); }); check('default picker shows the saved default', await admin.evaluate(() => document.querySelector('[data-nm-default] [data-nm-palette="blue"]').getAttribute('aria-pressed') === 'true' && document.querySelector('[data-nm-default] [data-nm-mode="dark"]').getAttribute('aria-pressed') === 'true')); // 2. A user without a choice gets the default const jdoe = await login('jdoe'); await expectLook('user without a choice gets the default', jdoe, 'dark standard normal blue'); // 3. The user's choice is saved on the account and only overrides that part check('choosing Light is saved (204)', (await pick(jdoe, '[data-nm-mode="light"]')) === 204); check('choosing Compact is saved (204)', (await pick(jdoe, '[data-nm-size="compact"]')) === 204); const jdoe2 = await login('jdoe'); await expectLook('choice follows the user to another browser', jdoe2, 'light standard compact blue'); // 4. Parts the user didn't change follow a new default; the user's Compact beats the default Large await saveDefault(admin, async () => { await admin.click('[data-nm-default] [data-nm-palette="green"]'); await admin.click('[data-nm-default] [data-nm-size="large"]'); }); await jdoe2.reload({ waitUntil: 'networkidle' }); await expectLook('unchanged parts follow the new default', jdoe2, 'light standard compact green'); // 5. Reset to default check('Reset to default is shown after a change', await jdoe2.isVisible('#account [data-nm-reset]') || await jdoe2.evaluate(() => !document.querySelector('#account [data-nm-reset]').hidden)); check('reset is saved (204)', (await pick(jdoe2, '[data-nm-reset]')) === 204); await expectLook('reset returns to the default', jdoe2, 'dark standard large green'); await jdoe2.reload({ waitUntil: 'networkidle' }); await expectLook('reset is kept after reload', jdoe2, 'dark standard large green'); check('Reset to default is hidden without a choice', await jdoe2.evaluate(() => document.querySelector('#account [data-nm-reset]').hidden)); // 5b. A custom color, with its generated CSS, is saved on the account const savedColor = jdoe2.waitForResponse((r) => r.url().includes('/tonalmine/appearance')); await pickColor(jdoe2, '#account', '#c0392b'); check('custom color is saved (204)', (await savedColor).status() === 204); const jdoe3 = await login('jdoe'); await expectLook('custom color follows the user', jdoe3, 'dark standard large custom'); check('custom color CSS comes from the account', (await customCss(jdoe3)) > 1000); await pick(jdoe3, '[data-nm-reset]'); // 6. A localStorage choice from before the plugin moves to the account once const msmith = await login('msmith', 'password123', () => localStorage.setItem('tonalmine', '{"contrast":"high"}')); await expectLook('localStorage choice is applied after login', msmith, 'dark high large green'); check('localStorage is cleared for logged-in users', await msmith.evaluate(() => localStorage.getItem('tonalmine') === null)); const msmith2 = await login('msmith'); await expectLook('uploaded choice is on the account', msmith2, 'dark high large green'); // 7. Validation; the request without a CSRF token goes last, since Redmine then ends the session const pweber = await login('pweber'); check('valid choice is saved (204)', (await put(pweber, { contrast: 'medium' })) === 204); for (const body of [{ mode: 'neon' }, { palette: 'custom' }, { palette: 'custom', seed: '#123456', css: '' }, { size: 'huge' }, { foo: 1 }, { mode: 5 }]) { const status = await put(pweber, body); check(`invalid ${JSON.stringify(body)} is refused (422)`, status === 422, `got ${status}`); } const tokenless = await put(pweber, { contrast: 'high' }, false); check('request without CSRF token is refused (422)', tokenless === 422, `got ${tokenless}`); const pweber2 = await login('pweber'); await expectLook('refused requests left the choice unchanged', pweber2, 'dark medium large green'); // 8. Anonymous visitors get the default const anonymous = await (await browser.newContext()).newPage(); await anonymous.goto(`${BASE}/login`); await expectLook('anonymous visitor gets the default', anonymous, 'dark standard large green'); // 8b. A custom default color reaches everyone await saveDefault(admin, async () => { await pickColor(admin, '[data-nm-default]', '#1e88e5'); await admin.waitForFunction(() => document.querySelector('[data-nm-default-input="css"]').value.length > 1000); }); await anonymous.reload(); await expectLook('custom default reaches anonymous visitors', anonymous, 'dark standard large custom'); check('custom default CSS is rendered', (await customCss(anonymous)) > 1000); // 9. Clean up: empty default, so other checks and screenshots see the built-in look await saveDefault(admin, () => admin.$$eval('[data-nm-default-input]', (inputs) => inputs.forEach((i) => { i.value = ''; }))); await anonymous.reload(); await expectLook('empty default restores the built-in look', anonymous, 'auto standard normal purple'); await Promise.all(['jdoe', 'msmith', 'pweber'].map(resetUser)); await browser.close(); process.exit(failures ? 1 : 0);