Files
redmine-theme-tonalmine/e2e/check-appearance.mjs
T
Andreas Reinhold / reiniandClaude Opus 5.5 20b7269df2 feat: redmine_tonalmine plugin for default and per-account appearance
Admins set the default mode, contrast and color on Settings > Display,
shown as three labelled form rows below the Theme select. Logged-in
users' choices are saved on their account and override only the parts
they changed; anonymous visitors get the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:12:44 +02:00

168 lines
8.8 KiB
JavaScript

// 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 <html> attributes theme.js sets; absent means the built-in default (auto, standard, purple).
const looks = (page) => page.evaluate(() => {
const d = document.documentElement.dataset;
return `${d.mode ?? 'auto'} ${d.contrast ?? 'standard'} ${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 and color are three form rows
const rowsShown = () => admin.$$eval('p:has([data-nm-default])', (rows) => rows.length === 3 && 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 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);
const jdoe2 = await login('jdoe');
await expectLook('choice follows the user to another browser', jdoe2, 'light standard blue');
// 4. Parts the user didn't change follow a new default
await saveDefault(admin, () => admin.click('[data-nm-default] [data-nm-palette="green"]'));
await jdoe2.reload({ waitUntil: 'networkidle' });
await expectLook('unchanged parts follow the new default', jdoe2, 'light standard 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 green');
await jdoe2.reload({ waitUntil: 'networkidle' });
await expectLook('reset is kept after reload', jdoe2, 'dark standard 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 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 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 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: '</style>' }, { 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 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 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 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 purple');
await Promise.all(['jdoe', 'msmith', 'pweber'].map(resetUser));
await browser.close();
process.exit(failures ? 1 : 0);