mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 17:15:24 +00:00
349 lines
17 KiB
JavaScript
349 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Gradient role colors: a role can draw names as a gradient from its color
|
|
* to a second color, optionally shimmering. Covers saving the new fields,
|
|
* rejecting bad colors, carrying them to every place a role color reaches a
|
|
* client, and the client helpers that paint names with them.
|
|
*
|
|
* Boots a server on a scratch port and data dir:
|
|
* node --test test/gradientRoles.test.js
|
|
*/
|
|
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const http = require('node:http');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const vm = require('node:vm');
|
|
const { spawn } = require('node:child_process');
|
|
const { io } = require('socket.io-client');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const PORT = 3417;
|
|
const BASE = `http://localhost:${PORT}`;
|
|
const DATA = path.join(os.tmpdir(), `haven-gradient-roles-${Date.now()}`);
|
|
|
|
let server;
|
|
|
|
const post = (p, body) => new Promise((res, rej) => {
|
|
const d = JSON.stringify(body);
|
|
const r = http.request({ host: 'localhost', port: PORT, path: p, method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(d) } },
|
|
(x) => { let b = ''; x.on('data', (c) => (b += c)); x.on('end', () => { try { res(JSON.parse(b)); } catch { res({ raw: b }); } }); });
|
|
r.on('error', rej); r.write(d); r.end();
|
|
});
|
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const register = (username) => post('/api/auth/register', { username, password: 'gradient-pass-321', eulaVersion: '2.0', ageVerified: true });
|
|
const ask = (sock, event, payload) => new Promise((res) => sock.emit(event, payload, res));
|
|
|
|
// The same fill-in the web client does for slimmed member entries (app-socket.js).
|
|
const fillMember = (u) => ({
|
|
highScore: 0, statusText: '', avatar: null, avatarShape: 'circle', border: null,
|
|
borderTransform: null, animateProfile: 'trigger', isGuest: false, role: null, activity: null, birthday: null,
|
|
...u,
|
|
});
|
|
|
|
function client(token, deltas) {
|
|
const s = io(BASE, { auth: deltas ? { token, presenceDeltas: 1 } : { token }, transports: ['websocket'], forceNew: true });
|
|
s.lists = new Map();
|
|
s.on('online-users', (d) => s.lists.set(d.channelCode, d.slim ? d.users.map(fillMember) : d.users));
|
|
s.on('online-users-delta', (d) => {
|
|
const list = s.lists.get(d.channelCode);
|
|
if (!list) return;
|
|
const upsert = d.upsert.map(fillMember);
|
|
const drop = new Set([...d.remove, ...upsert.map(u => u.id)]);
|
|
s.lists.set(d.channelCode, list.filter(u => !drop.has(u.id)).concat(upsert));
|
|
});
|
|
return new Promise((res, rej) => { s.on('connect', () => res(s)); s.on('connect_error', rej); });
|
|
}
|
|
|
|
function next(sock, event, filter = () => true, ms = 4000) {
|
|
return new Promise((res) => {
|
|
const t = setTimeout(() => { sock.off(event, h); res(null); }, ms);
|
|
const h = (data) => { if (!filter(data)) return; clearTimeout(t); sock.off(event, h); res(data); };
|
|
sock.on(event, h);
|
|
});
|
|
}
|
|
|
|
const roleById = async (sock, id) => (await ask(sock, 'get-roles', {})).roles.find(r => r.id === id);
|
|
const member = (sock, code, username) => (sock.lists.get(code) || []).find(u => u.username === username);
|
|
|
|
test.before(async () => {
|
|
fs.mkdirSync(DATA, { recursive: true });
|
|
server = spawn(process.execPath, ['server.js'], {
|
|
cwd: ROOT,
|
|
env: { ...process.env, PORT: String(PORT), HAVEN_DATA_DIR: DATA, ADMIN_USERNAME: 'admin', FORCE_HTTP: 'true' },
|
|
stdio: 'ignore',
|
|
});
|
|
for (let i = 0; i < 60; i++) {
|
|
try {
|
|
await new Promise((res, rej) => http.get(`${BASE}/api/health`, (r) => (r.statusCode === 200 ? res() : rej())).on('error', rej));
|
|
return;
|
|
} catch { await wait(500); }
|
|
}
|
|
throw new Error('server did not start');
|
|
});
|
|
|
|
test.after(() => { server?.kill(); try { fs.rmSync(DATA, { recursive: true, force: true }); } catch {} });
|
|
|
|
test('gradient roles: saved, validated and carried to clients', async (t) => {
|
|
const admin = await register('admin');
|
|
const bob = await register('bob');
|
|
const dave = await register('dave');
|
|
assert.ok(admin.token && bob.token && dave.token, 'all registered');
|
|
|
|
const A = await client(admin.token, true);
|
|
const made = next(A, 'channels-list', (chs) => Array.isArray(chs) && chs.some((c) => c.name === 'lounge'));
|
|
A.emit('create-channel', { name: 'lounge' });
|
|
const code = (await made).find((c) => c.name === 'lounge').code;
|
|
A.emit('enter-channel', { code });
|
|
const B = await client(bob.token, true);
|
|
const L = await client(dave.token, false); // an older app, full lists only
|
|
for (const s of [B, L]) { s.emit('join-channel', { code }); await wait(100); s.emit('enter-channel', { code }); }
|
|
await wait(800);
|
|
|
|
const rare = await ask(A, 'create-role', { name: 'Rare', level: 5, color: '#ff0000', color2: '#0000ff', shimmer: true });
|
|
assert.ok(rare.roleId, 'gradient role created');
|
|
|
|
await t.test('create stores the end color and shimmer', async () => {
|
|
const r = await roleById(A, rare.roleId);
|
|
assert.strictEqual(r.color, '#ff0000');
|
|
assert.strictEqual(r.color2, '#0000ff');
|
|
assert.strictEqual(r.color_shimmer, 1);
|
|
});
|
|
|
|
await t.test('a role without an end color is a plain color, as before', async () => {
|
|
const plain = await ask(A, 'create-role', { name: 'Plain', level: 3, color: '#00aa00', shimmer: true });
|
|
const r = await roleById(A, plain.roleId);
|
|
assert.strictEqual(r.color2, null);
|
|
assert.strictEqual(r.color_shimmer, 0, 'shimmer means nothing without a gradient');
|
|
});
|
|
|
|
await t.test('bad end colors are refused like bad colors', async () => {
|
|
for (const bad of ['red', 'url(x)', '#12345g', '#1234567', 'javascript:alert(1)', 42, { a: 1 }]) {
|
|
const res = await ask(A, 'create-role', { name: 'Bad', level: 2, color: '#123456', color2: bad, shimmer: true });
|
|
const r = await roleById(A, res.roleId);
|
|
assert.strictEqual(r.color2, null, `color2 ${JSON.stringify(bad)} stored as no color`);
|
|
assert.strictEqual(r.color_shimmer, 0);
|
|
await ask(A, 'delete-role', { roleId: res.roleId });
|
|
}
|
|
});
|
|
|
|
await t.test('update changes, keeps and clears the gradient', async () => {
|
|
const tmp = await ask(A, 'create-role', { name: 'Tmp', level: 4, color: '#111111', color2: '#222222' });
|
|
let res = await ask(A, 'update-role', { roleId: tmp.roleId, color2: '#abc', shimmer: true });
|
|
assert.ok(res.success);
|
|
let r = await roleById(A, tmp.roleId);
|
|
assert.strictEqual(r.color2, '#abc');
|
|
assert.strictEqual(r.color_shimmer, 1);
|
|
|
|
await ask(A, 'update-role', { roleId: tmp.roleId, name: 'Tmp2' });
|
|
r = await roleById(A, tmp.roleId);
|
|
assert.strictEqual(r.color2, '#abc', 'an edit that does not send color2 leaves the gradient alone');
|
|
assert.strictEqual(r.color_shimmer, 1);
|
|
|
|
await ask(A, 'update-role', { roleId: tmp.roleId, shimmer: false });
|
|
assert.strictEqual((await roleById(A, tmp.roleId)).color_shimmer, 0);
|
|
|
|
await ask(A, 'update-role', { roleId: tmp.roleId, color2: 'not-a-color', shimmer: true });
|
|
r = await roleById(A, tmp.roleId);
|
|
assert.strictEqual(r.color2, null, 'a bad end color clears the gradient');
|
|
assert.strictEqual(r.color_shimmer, 0, 'and its shimmer');
|
|
|
|
await ask(A, 'update-role', { roleId: tmp.roleId, color2: null });
|
|
assert.strictEqual((await roleById(A, tmp.roleId)).color2, null);
|
|
await ask(A, 'delete-role', { roleId: tmp.roleId });
|
|
});
|
|
|
|
await t.test('duplicate carries the gradient', async () => {
|
|
// Duplicate in the role editor re-creates the role from its row; this is
|
|
// the payload it sends (app-roles.js).
|
|
const src = fs.readFileSync(path.join(ROOT, 'public/js/modules/app-roles.js'), 'utf8');
|
|
const dup = src.slice(src.indexOf("getElementById('duplicate-role-btn')?.addEventListener"));
|
|
assert.match(dup.slice(0, 1500), /color2: role\.color2 \|\| null,\s*shimmer: !!role\.color_shimmer,/);
|
|
|
|
const orig = await roleById(A, rare.roleId);
|
|
const copy = await ask(A, 'create-role', {
|
|
name: 'Rare (copy)', level: orig.level, color: orig.color, color2: orig.color2 || null,
|
|
shimmer: !!orig.color_shimmer, transparent: !!orig.transparent, permissions: orig.permissions,
|
|
});
|
|
const r = await roleById(A, copy.roleId);
|
|
assert.strictEqual(r.color2, '#0000ff');
|
|
assert.strictEqual(r.color_shimmer, 1);
|
|
await ask(A, 'delete-role', { roleId: copy.roleId });
|
|
});
|
|
|
|
await ask(A, 'assign-role', { userId: bob.user.id, roleId: rare.roleId });
|
|
await wait(1500);
|
|
|
|
await t.test('member list entries carry the gradient, slim or whole', async () => {
|
|
for (const [who, sock] of [['deltas client', A], ['older client', L]]) {
|
|
const entry = member(sock, code, 'bob');
|
|
assert.ok(entry && entry.role, `${who} sees bob with a role`);
|
|
assert.strictEqual(entry.role.color, '#ff0000');
|
|
assert.strictEqual(entry.role.color2, '#0000ff', `${who} gets the end color`);
|
|
assert.strictEqual(entry.role.color_shimmer, 1, `${who} gets the shimmer`);
|
|
}
|
|
const adminEntry = member(A, code, 'admin');
|
|
assert.ok(adminEntry && adminEntry.role, 'the admin has their Admin role');
|
|
assert.ok(!('color2' in adminEntry.role), 'a plain role sends no gradient fields');
|
|
assert.deepStrictEqual(member(A, code, 'bob'), member(L, code, 'bob'), 'same entry either way');
|
|
});
|
|
|
|
await t.test('a transparent role on top leaves the gradient role styling the name', async () => {
|
|
const ghost = await ask(A, 'create-role', { name: 'Ghost', level: 40, color: '#999999', transparent: true });
|
|
await ask(A, 'assign-role', { userId: bob.user.id, roleId: ghost.roleId });
|
|
await wait(1500);
|
|
const entry = member(A, code, 'bob');
|
|
assert.strictEqual(entry.role.name, 'Rare');
|
|
assert.strictEqual(entry.role.color2, '#0000ff');
|
|
});
|
|
|
|
await t.test('a message author is styled by their member entry, which has the gradient', async () => {
|
|
const got = next(A, 'new-message', (d) => d && d.channelCode === code && d.message && d.message.content === 'shiny');
|
|
B.emit('send-message', { code, content: 'shiny' });
|
|
const msg = await got;
|
|
assert.ok(msg, 'message arrived');
|
|
const author = member(A, code, 'bob');
|
|
assert.strictEqual(author.id, msg.message.user_id);
|
|
assert.strictEqual(author.role.color2, '#0000ff');
|
|
// The chat renders that author through the shared helper.
|
|
const src = fs.readFileSync(path.join(ROOT, 'public/js/modules/app-messages.js'), 'utf8');
|
|
assert.match(src, /this\._roleNameHtml\(onlineUser\.role, authorText\)/);
|
|
assert.match(src, /<span class="message-author" style="color:\$\{authorColor\}"[^\n]*>\$\{authorHtml\}<\/span>/);
|
|
});
|
|
|
|
await t.test('profile cards, role lookups and voice rosters carry it', async () => {
|
|
const profile = next(A, 'user-profile', (p) => p && p.id === bob.user.id);
|
|
A.emit('get-user-profile', { userId: bob.user.id });
|
|
const p = await profile;
|
|
const shown = p.roles.find(r => r.name === 'Rare');
|
|
assert.strictEqual(shown.color2, '#0000ff');
|
|
assert.strictEqual(shown.color_shimmer, 1);
|
|
|
|
const roles = next(A, 'user-roles', (d) => d && d.userId === bob.user.id);
|
|
A.emit('get-user-roles', { userId: bob.user.id });
|
|
const ur = await roles;
|
|
assert.strictEqual(ur.highestRole.name, 'Rare');
|
|
assert.strictEqual(ur.highestRole.color2, '#0000ff');
|
|
assert.strictEqual(ur.roles.find(r => r.name === 'Rare').color2, '#0000ff');
|
|
|
|
const voice = next(A, 'voice-users-update', (d) => d && d.channelCode === code && d.users.some(u => u.id === bob.user.id));
|
|
B.emit('voice-join', { code });
|
|
const v = await voice;
|
|
assert.ok(v, 'bob joined voice');
|
|
const vb = v.users.find(u => u.id === bob.user.id);
|
|
assert.strictEqual(vb.roleColor, '#ff0000', 'the old field is unchanged');
|
|
assert.strictEqual(vb.roleColor2, '#0000ff');
|
|
assert.strictEqual(vb.roleShimmer, true);
|
|
B.emit('voice-leave', { code });
|
|
});
|
|
|
|
await t.test('Reset to Default leaves stock roles without gradients', async () => {
|
|
const res = await ask(A, 'reset-roles-to-default', {});
|
|
assert.ok(res.success);
|
|
const { roles } = await ask(A, 'get-roles', {});
|
|
assert.ok(roles.length > 0);
|
|
for (const r of roles) {
|
|
assert.strictEqual(r.color2, null, `${r.name} has no end color`);
|
|
assert.strictEqual(r.color_shimmer, 0);
|
|
}
|
|
});
|
|
|
|
for (const s of [A, B, L]) s.close();
|
|
});
|
|
|
|
// ── client helpers ────────────────────────────────────────────────────────
|
|
function loadApp() {
|
|
const source = fs.readFileSync(path.join(ROOT, 'public/js/modules/app-utilities.js'), 'utf8');
|
|
const store = new Map();
|
|
const context = vm.createContext({
|
|
module: { exports: {} }, exports: {},
|
|
document: {
|
|
documentElement: { lang: 'en-US' }, querySelectorAll: () => [],
|
|
createElement: () => {
|
|
let text = '';
|
|
return {
|
|
set textContent(v) { text = String(v); },
|
|
get innerHTML() { return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); },
|
|
};
|
|
},
|
|
},
|
|
localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
|
|
navigator: { languages: ['en-US'] },
|
|
setInterval: () => 0, clearInterval: () => {},
|
|
Intl, Date, Number, Math, JSON, String, Array, Object, RegExp, Error, Map, Set,
|
|
t: (key) => key, console,
|
|
});
|
|
vm.runInContext(source.replace(/^export default/m, 'module.exports ='), context, { filename: 'app-utilities.js' });
|
|
const app = Object.create(context.module.exports);
|
|
app._escapeHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
app._store = store;
|
|
return app;
|
|
}
|
|
|
|
test('a plain role leaves the name text alone', () => {
|
|
const app = loadApp();
|
|
assert.strictEqual(app._roleNameHtml({ color: '#ff0000' }, 'Bob <3'), 'Bob <3');
|
|
assert.strictEqual(app._roleNameHtml(null, 'Bob'), 'Bob');
|
|
assert.strictEqual(app._roleFill({ color: '#ff0000' }, '#aaa'), '#ff0000');
|
|
assert.strictEqual(app._roleFill(null, '#aaa'), '#aaa');
|
|
});
|
|
|
|
test('a gradient role wraps the name in a gradient span, shimmering when asked', () => {
|
|
const app = loadApp();
|
|
const still = app._roleNameHtml({ color: '#ff0000', color2: '#0000ff', color_shimmer: 0 }, 'Bob');
|
|
assert.strictEqual(still, '<span class="role-gradient" style="--role-c1:#ff0000;--role-c2:#0000ff">Bob</span>');
|
|
const moving = app._roleNameHtml({ color: '#ff0000', color2: '#0000ff', color_shimmer: 1 }, 'Bob');
|
|
assert.match(moving, /class="role-gradient role-shimmer"/);
|
|
assert.strictEqual(app._roleFill({ color: '#ff0000', color2: '#0000ff' }), 'linear-gradient(90deg, #ff0000, #0000ff)');
|
|
// The flat voice shape works the same way.
|
|
assert.match(app._roleNameHtml({ roleColor: '#ff0000', roleColor2: '#0000ff', roleShimmer: true }, 'Bob'), /role-shimmer/);
|
|
});
|
|
|
|
test('emoji in a gradient name get their own span so they keep their colors (#5720)', () => {
|
|
const app = loadApp();
|
|
const role = { color: '#ff0000', color2: '#0000ff' };
|
|
const wrap = (name) => app._roleNameHtml(role, name).replace(/^<span[^>]*>|<\/span>$/g, '');
|
|
assert.strictEqual(wrap('Bob 🎉'), 'Bob <span class="role-emoji">🎉</span>');
|
|
// A ZWJ family, a skin tone, a flag and a keycap each stay one emoji, and a
|
|
// run of emoji shares one span.
|
|
assert.strictEqual(wrap('👨👩👧 a'), '<span class="role-emoji">👨👩👧</span> a');
|
|
assert.strictEqual(wrap('👍🏽'), '<span class="role-emoji">👍🏽</span>');
|
|
assert.strictEqual(wrap('🇧🇷'), '<span class="role-emoji">🇧🇷</span>');
|
|
assert.strictEqual(wrap('#️⃣1'), '<span class="role-emoji">#️⃣</span>1');
|
|
assert.strictEqual(wrap('🔥🔥 hot'), '<span class="role-emoji">🔥🔥</span> hot');
|
|
// Escaping is untouched, and entities never count as emoji.
|
|
assert.strictEqual(wrap('Bob <3 & #1'), 'Bob <3 & #1');
|
|
// A plain color name has no spans added.
|
|
assert.strictEqual(app._roleNameHtml({ color: '#ff0000' }, 'Bob 🎉'), 'Bob 🎉');
|
|
const css = require('./coreCss').readCoreCss();
|
|
// Colored only when Interface Icons is not Monochrome.
|
|
assert.match(css, /:root:not\(\[data-toolbaricons="mono"\]\) \.role-gradient \.role-emoji \{[^}]*-webkit-text-fill-color: currentColor/);
|
|
});
|
|
|
|
test('unsafe colors never reach the page', () => {
|
|
const app = loadApp();
|
|
assert.strictEqual(app._roleNameHtml({ color: '#ff0000', color2: 'red;background:url(x)' }, 'Bob'), 'Bob');
|
|
assert.strictEqual(app._roleNameHtml({ color: 'expression(x)', color2: '#0000ff' }, 'Bob'), 'Bob', 'no start color, no gradient');
|
|
assert.strictEqual(app._roleLook({ color: '#ff0000', color2: null, color_shimmer: 1 }).shimmer, false);
|
|
});
|
|
|
|
test('chat names follow the role display setting', () => {
|
|
const app = loadApp();
|
|
const role = { color: '#ff0000', color2: '#0000ff' };
|
|
app._lastOnlineUsers = [{ id: 5, username: 'bob', role }];
|
|
assert.strictEqual(app._chatNameRole(5), role);
|
|
assert.strictEqual(app._chatNameRole(6), null, 'unknown user');
|
|
app._store.set('haven-role-display', 'dot');
|
|
assert.strictEqual(app._chatNameRole(5), null, 'names are not role colored in dot mode');
|
|
});
|
|
|
|
test('shimmer is CSS only and stops for reduced motion', () => {
|
|
const css = require('./coreCss').readCoreCss();
|
|
assert.match(css, /\.role-gradient\.role-shimmer \{[^}]*animation: role-shimmer/);
|
|
// html.reduce-motion is the OS setting or the Accessibility override (theme-init.js).
|
|
assert.match(css, /html\.reduce-motion \.role-gradient\.role-shimmer \{ animation: none; \}/);
|
|
});
|