Files
Haven/test/chromeLayout.test.js
ancsemi ffb27a6463 Chrome layout: show menu labels in the user's language where Haven has them (#5681)
The + menu, the home server menu, the DM dock button, the row voice buttons
and the temporary channel prompt now use Haven's existing translations, and
build their buttons with text instead of HTML. Labels Haven has no string
for yet stay in English.
2026-10-05 18:17:01 -04:00

723 lines
25 KiB
JavaScript

'use strict';
/**
* Tests for ChromeLayout layout plugin.
*
* node --test test/chromeLayout.test.js
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const ChromeLayout = require('../plugins/ChromeLayout.plugin.js');
const ROOT = path.join(__dirname, '..');
class FakeTarget {
constructor() {
this.listeners = new Map();
}
addEventListener(type, listener) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).add(listener);
}
removeEventListener(type, listener) {
this.listeners.get(type)?.delete(listener);
}
dispatchEvent(event) {
for (const listener of [...(this.listeners.get(event.type) || [])]) {
listener.call(this, event);
}
}
}
class FakeStyle {
constructor() {
this.values = new Map();
}
setProperty(property, value, priority = '') {
this.values.set(property, { value, priority });
}
removeProperty(property) {
this.values.delete(property);
}
getPropertyValue(property) {
return this.values.get(property)?.value || '';
}
getPropertyPriority(property) {
return this.values.get(property)?.priority || '';
}
}
class FakeElement extends FakeTarget {
constructor(name, registry = null) {
super();
this.name = name;
this._registry = registry;
this.parentNode = null;
this.children = [];
this.attributes = new Map();
this.dataset = {};
this.style = new FakeStyle();
this._classes = new Set();
const self = this;
this.classList = {
add(...names) { names.forEach(name => self._classes.add(name)); },
remove(...names) { names.forEach(name => self._classes.delete(name)); },
contains(name) { return self._classes.has(name); },
toggle(name, force) {
const enabled = force === undefined ? !self._classes.has(name) : Boolean(force);
if (enabled) self._classes.add(name); else self._classes.delete(name);
return enabled;
}
};
this.textContent = '';
this.title = '';
this.type = '';
this._id = '';
this.hidden = false;
}
get className() {
return [...this._classes].join(' ');
}
set className(names) {
this._classes.clear();
String(names || '').split(/\s+/).filter(Boolean).forEach(n => this._classes.add(n));
}
get id() {
return this._id;
}
set id(value) {
this._id = value;
if (this._registry) this._registry.set(value, this);
}
get firstChild() {
return this.children[0] || null;
}
get nextSibling() {
if (!this.parentNode) return null;
const index = this.parentNode.children.indexOf(this);
return this.parentNode.children[index + 1] || null;
}
append(...nodes) {
for (const node of nodes) this.insertBefore(node, null);
}
appendChild(node) {
return this.insertBefore(node, null);
}
prepend(node) {
this.insertBefore(node, this.firstChild);
}
insertBefore(node, reference) {
if (reference && reference.parentNode !== this) throw new Error('Invalid insertion reference');
if (node.parentNode) {
const previousIndex = node.parentNode.children.indexOf(node);
node.parentNode.children.splice(previousIndex, 1);
}
const index = reference ? this.children.indexOf(reference) : this.children.length;
this.children.splice(index, 0, node);
node.parentNode = this;
return node;
}
remove() {
if (this._registry && this._id) {
this._registry.delete(this._id);
}
if (!this.parentNode) return;
const index = this.parentNode.children.indexOf(this);
this.parentNode.children.splice(index, 1);
this.parentNode = null;
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
if (name === 'id') this.id = value;
}
removeAttribute(name) {
this.attributes.delete(name);
}
hasAttribute(name) {
return this.attributes.has(name);
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
getBoundingClientRect() {
return { left: 100, top: 100, right: 150, bottom: 150, width: 50, height: 50 };
}
querySelector(sel) {
for (const child of this.children) {
if (matchesSelector(child, sel)) return child;
const found = child.querySelector(sel);
if (found) return found;
}
return null;
}
querySelectorAll(sel) {
const results = [];
for (const child of this.children) {
if (matchesSelector(child, sel)) results.push(child);
results.push(...child.querySelectorAll(sel));
}
return results;
}
contains(el) {
if (!el) return false;
let curr = el;
while (curr) {
if (curr === this) return true;
curr = curr.parentNode;
}
return false;
}
}
function matchesSimple(el, sel) {
if (sel.startsWith('#')) return el.id === sel.slice(1);
if (sel.startsWith('.')) return el.classList.contains(sel.slice(1));
if (sel === 'h5') return el.name === 'h5';
if (sel.startsWith('[data-action="') && sel.endsWith('"]')) {
const action = sel.slice(14, -2);
return el.dataset?.action === action;
}
return false;
}
function matchesSelector(el, sel) {
const parts = sel.trim().split(/\s+/);
if (parts.length === 1) return matchesSimple(el, parts[0]);
if (!matchesSimple(el, parts[parts.length - 1])) return false;
let curr = el.parentNode;
for (let i = parts.length - 2; i >= 0; i--) {
while (curr && !matchesSimple(curr, parts[i])) {
curr = curr.parentNode;
}
if (!curr) return false;
curr = curr.parentNode;
}
return true;
}
function createEnvironment() {
const elementsById = new Map();
const document = new FakeTarget();
document.documentElement = new FakeElement('html', elementsById);
document.body = new FakeElement('body', elementsById);
document.documentElement.appendChild(document.body);
document.getElementById = (id) => elementsById.get(id) || null;
document.createElement = (name) => {
return new FakeElement(name, elementsById);
};
document.querySelector = (sel) => document.documentElement.querySelector(sel);
document.querySelectorAll = (sel) => document.documentElement.querySelectorAll(sel);
const add = (id, el) => {
el.id = id;
elementsById.set(id, el);
return el;
};
const appBody = add('app-body', new FakeElement('div', elementsById));
document.body.appendChild(appBody);
const sidebar = new FakeElement('aside', elementsById);
sidebar.classList.add('sidebar');
appBody.appendChild(sidebar);
const channelsToggle = add('channels-toggle', new FakeElement('h5', elementsById));
channelsToggle.classList.add('channels-toggle');
sidebar.appendChild(channelsToggle);
const subBtn = add('sub-channel-panel-btn', new FakeElement('button', elementsById));
const orgBtn = add('organize-channels-btn', new FakeElement('button', elementsById));
channelsToggle.append(subBtn, orgBtn);
const channelList = add('channel-list', new FakeElement('div', elementsById));
channelList.classList.add('channel-list');
sidebar.appendChild(channelList);
const row = new FakeElement('div', elementsById);
row.classList.add('channel-item');
row.dataset.code = 'general';
const moreBtn = new FakeElement('button', elementsById);
moreBtn.classList.add('channel-more-btn');
row.appendChild(moreBtn);
channelList.appendChild(row);
const bottomBar = new FakeElement('div', elementsById);
bottomBar.classList.add('sidebar-bottom-bar');
sidebar.appendChild(bottomBar);
const threadsBtn = add('threads-toggle-btn', new FakeElement('button', elementsById));
sidebar.appendChild(threadsBtn);
const dmPane = add('dm-pane', new FakeElement('div', elementsById));
dmPane.classList.add('dm-section-pane');
const dmHeader = add('dm-toggle-header', new FakeElement('h5', elementsById));
const dmArrow = add('dm-toggle-arrow', new FakeElement('span', elementsById));
const dmList = add('dm-list', new FakeElement('div', elementsById));
const dmBadge = add('dm-unread-badge', new FakeElement('span', elementsById));
dmBadge.classList.add('dm-unread-count');
const organizeDms = add('organize-dms-btn', new FakeElement('button', elementsById));
dmHeader.append(dmArrow, dmBadge, organizeDms);
dmPane.append(dmHeader, dmList);
sidebar.appendChild(dmPane);
const homeServer = add('home-server', new FakeElement('div', elementsById));
homeServer.classList.add('server-icon');
appBody.appendChild(homeServer);
const styles = new Map();
const data = new Map();
let layoutOwner = null;
const HavenApi = {
DOM: {
addStyle(id, css) { styles.set(id, css); },
removeStyle(id) { styles.delete(id); },
query(sel) { return document.querySelector(sel); },
queryAll(sel) { return document.querySelectorAll(sel); }
},
Layout: {
acquire(owner) {
if (layoutOwner && layoutOwner !== owner) return false;
layoutOwner = owner;
document.documentElement.setAttribute('data-haven-layout-owner', owner);
document.dispatchEvent({ type: 'haven:layout-owner-change', detail: { owner } });
return true;
},
release(owner) {
if (layoutOwner !== owner) return false;
layoutOwner = null;
document.documentElement.removeAttribute('data-haven-layout-owner');
document.dispatchEvent({ type: 'haven:layout-owner-change', detail: { owner: null } });
return true;
},
get owner() { return layoutOwner; }
},
Data: {
save(plugin, key, val) {
if (!data.has(plugin)) data.set(plugin, new Map());
data.get(plugin).set(key, val);
},
load(plugin, key, fallback = null) {
return data.get(plugin)?.get(key) ?? fallback;
}
}
};
const app = {
user: { isAdmin: true },
channels: [{ code: 'general', name: 'General', voice_enabled: 1 }],
unreadCounts: { general: 0 },
voice: { inVoice: false, currentChannel: null }
};
return { document, HavenApi, app, styles, data, elementsById };
}
function withEnv(fn) {
const env = createEnvironment();
const prevDoc = global.document;
const prevHaven = global.HavenApi;
const prevApp = global.app;
const prevWindow = global.window;
global.document = env.document;
global.HavenApi = env.HavenApi;
global.app = env.app;
global.window = { app: env.app, HavenApi: env.HavenApi, innerWidth: 1200 };
try {
fn(env);
} finally {
global.document = prevDoc;
global.HavenApi = prevHaven;
global.app = prevApp;
global.window = prevWindow;
}
}
test('ChromeLayout file and class meet the Layout Picker requirements', () => {
const source = fs.readFileSync(path.join(ROOT, 'plugins/ChromeLayout.plugin.js'), 'utf8');
assert.match(source, /@name Chrome Layout/);
assert.match(source, /class ChromeLayout/);
assert.match(source, /_engage\(/);
assert.match(source, /_disengage\(/);
assert.match(source, /acquire\(['"]ChromeLayout['"]\)/);
assert.match(source, /layoutOn/);
});
test('ChromeLayout engages and injects prominent +, dock buttons, and channel voice buttons', () => {
withEnv(({ document, HavenApi }) => {
const plugin = new ChromeLayout();
plugin.start();
assert.equal(document.documentElement.getAttribute('data-chrome-layout'), '1');
assert.equal(HavenApi.Layout.owner, 'ChromeLayout');
// Prominent + button injected
const addBtn = document.getElementById('channel-actions-btn');
assert.ok(addBtn, '#channel-actions-btn should be injected');
assert.ok(addBtn.classList.contains('channel-actions-add-btn'), 'should have class channel-actions-add-btn');
assert.ok(addBtn.classList.contains('chrome-chip'), 'should have class chrome-chip');
// Sheet menu injected
const menu = document.getElementById('channel-actions-menu');
assert.ok(menu, '#channel-actions-menu should be injected');
assert.equal(menu.hidden, true);
// Footer dock injected
const peopleBtn = document.getElementById('people-dock-btn');
const dmBtn = document.getElementById('dm-dock-btn');
assert.ok(peopleBtn, '#people-dock-btn should be injected');
assert.ok(dmBtn, '#dm-dock-btn should be injected');
// Channel voice button injected on channel row
const row = document.querySelector('.channel-item');
const voiceBtn = row.querySelector('.channel-join-voice');
assert.ok(voiceBtn, '.channel-join-voice should be injected on channel item');
assert.equal(voiceBtn.dataset.joinVoice, 'general');
// Home server menu bound
const homeMenu = document.getElementById('home-server-menu');
assert.ok(homeMenu, '#home-server-menu should exist');
plugin.stop();
// Clean teardown on stop
assert.equal(document.documentElement.hasAttribute('data-chrome-layout'), false);
assert.equal(HavenApi.Layout.owner, null);
assert.equal(document.getElementById('channel-actions-btn'), null);
assert.equal(document.getElementById('channel-actions-menu'), null);
assert.equal(document.getElementById('people-dock-btn'), null);
assert.equal(document.getElementById('dm-dock-btn'), null);
assert.equal(document.getElementById('home-server-menu'), null);
assert.equal(row.querySelector('.channel-join-voice'), null);
});
});
test('ChromeLayout respects structural layout owner exclusivity', () => {
withEnv(({ HavenApi }) => {
// Another layout owns the layout
HavenApi.Layout.acquire('BraidLayout');
const plugin = new ChromeLayout();
plugin.start();
assert.equal(HavenApi.Layout.owner, 'BraidLayout');
assert.equal(plugin._engaged, false);
assert.equal(plugin._blocked, true);
// Release and retry
HavenApi.Layout.release('BraidLayout');
assert.equal(HavenApi.Layout.owner, 'ChromeLayout');
assert.equal(plugin._engaged, true);
plugin.stop();
assert.equal(HavenApi.Layout.owner, null);
});
});
test('ChromeLayout suspends during Mod Mode and resumes when editing ends', () => {
withEnv(({ document, HavenApi }) => {
const plugin = new ChromeLayout();
plugin.start();
assert.equal(plugin._engaged, true);
assert.equal(HavenApi.Layout.owner, 'ChromeLayout');
assert.equal(document.documentElement.getAttribute('data-chrome-layout'), '1');
// Mod Mode editing starts
document.documentElement.setAttribute('data-haven-layout-editing', '1');
document.dispatchEvent({ type: 'haven:layout-editing', detail: { active: true } });
assert.equal(plugin._suspended, true);
assert.equal(HavenApi.Layout.owner, null);
assert.equal(document.documentElement.hasAttribute('data-chrome-layout'), false);
// Mod Mode editing finishes
document.documentElement.removeAttribute('data-haven-layout-editing');
document.dispatchEvent({ type: 'haven:layout-editing', detail: { active: false } });
assert.equal(plugin._suspended, false);
assert.equal(HavenApi.Layout.owner, 'ChromeLayout');
assert.equal(document.documentElement.getAttribute('data-chrome-layout'), '1');
plugin.stop();
});
});
test('ChromeLayout rolls back ownership and attributes when persistence fails', () => {
withEnv(({ document, HavenApi }) => {
const plugin = new ChromeLayout();
HavenApi.Data.save = () => { throw new Error('disk quota exceeded'); };
assert.throws(() => plugin._engage(true), /disk quota exceeded/);
assert.equal(plugin._engaged, false);
assert.equal(HavenApi.Layout.owner, null);
assert.equal(document.documentElement.hasAttribute('data-chrome-layout'), false);
});
});
test('ChromeLayout CSS is properly scoped to [data-chrome-layout="1"]', () => {
const css = ChromeLayout.CSS;
assert.match(css, /html\[data-chrome-layout="1"\] \.category-label/);
assert.match(css, /html\[data-chrome-layout="1"\] \.channels-toggle \.channel-actions-add-btn/);
assert.match(css, /html\[data-chrome-layout="1"\] #dm-dock-btn/);
assert.match(css, /html\[data-chrome-layout="1"\] #people-dock-btn/);
assert.match(css, /html\[data-chrome-layout="1"\] \.channel-join-voice/);
assert.match(css, /html\[data-chrome-layout="1"\] \.home-server-menu/);
assert.match(css, /html\[data-chrome-layout="1"\] \.sidebar-split-handle/);
assert.match(css, /#braid-return-pill/);
assert.match(css, /\[data-compact-layout-control\]/);
});
test('ChromeLayout DM drawer collapses down when toggle header is clicked', () => {
withEnv(({ document }) => {
const plugin = new ChromeLayout();
plugin.start();
// Open DM dock
plugin._setDmDockOpen(true);
assert.equal(document.documentElement.classList.contains('dms-open'), true);
const dmHeader = document.getElementById('dm-toggle-header');
assert.ok(dmHeader);
// Clicking header collapses down the DM drawer. The layout catches the
// click on the way down so Haven's own collapse handler never runs.
let stopped = false;
document.dispatchEvent({ type: 'click', target: dmHeader, stopPropagation() { stopped = true; } });
assert.equal(document.documentElement.classList.contains('dms-open'), false);
assert.equal(stopped, true);
// Opening and closing the drawer leaves Haven's DM list state alone.
const dmList = document.getElementById('dm-list');
const arrow = document.getElementById('dm-toggle-arrow');
dmList.style.display = 'none';
arrow.classList.add('collapsed');
plugin._setDmDockOpen(true);
plugin._setDmDockOpen(false);
assert.equal(dmList.style.display, 'none');
assert.equal(arrow.classList.contains('collapsed'), true);
// Buttons in the header (Organize DMs) still reach Haven.
stopped = false;
const organize = document.getElementById('organize-dms-btn');
organize.closest = () => organize;
document.dispatchEvent({ type: 'click', target: organize, stopPropagation() { stopped = true; } });
assert.equal(stopped, false);
plugin.stop();
});
});
test('ChromeLayout only rewrites a channel voice button when its state changes', () => {
withEnv(({ document, app }) => {
const plugin = new ChromeLayout();
plugin.start();
const btn = document.querySelector('.channel-item').querySelector('.channel-join-voice');
assert.ok(btn);
let html = btn.innerHTML;
let writes = 0;
Object.defineProperty(btn, 'innerHTML', {
get() { return html; },
set(value) { writes++; html = value; }
});
// Idle passes (what the observer runs) must not touch the button, or the
// write retriggers the observer every frame.
plugin._setupChannelVoiceButtons();
plugin._setupChannelVoiceButtons();
assert.equal(writes, 0);
app.voice.inVoice = true;
app.voice.currentChannel = 'general';
plugin._setupChannelVoiceButtons();
plugin._setupChannelVoiceButtons();
assert.equal(writes, 1);
assert.ok(btn.classList.contains('is-leave'));
plugin.stop();
});
});
test('ChromeLayout row voice button joins voice in that row\'s channel', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
withEnv(({ document, app }) => {
const calls = [];
app.currentChannel = 'other';
app.switchChannel = (code) => { calls.push(['switch', code]); app.currentChannel = code; };
app._joinVoice = (...args) => { calls.push(['join', app.currentChannel, args.length]); };
const plugin = new ChromeLayout();
plugin.start();
const btn = document.querySelector('.channel-item').querySelector('.channel-join-voice');
btn.dispatchEvent({ type: 'click', preventDefault() {}, stopPropagation() {} });
t.mock.timers.tick(300);
assert.deepEqual(calls, [['switch', 'general'], ['join', 'general', 0]]);
plugin.stop();
});
});
test('ChromeLayout home menu opens Settings through Haven\'s Settings button and keeps that button visible', () => {
withEnv(({ document, app, elementsById }) => {
const opened = [];
const settingsBtn = new FakeElement('button', elementsById);
settingsBtn.id = 'open-settings-btn';
settingsBtn.click = () => opened.push('open');
document.body.appendChild(settingsBtn);
app._switchSettingsTab = (tab) => opened.push(tab);
app._hasAnyAdminSettingsAccess = () => true;
const plugin = new ChromeLayout();
plugin.start();
const menu = document.getElementById('home-server-menu');
const item = (action) => ({ closest: () => ({ dataset: { homeAction: action } }) });
menu.dispatchEvent({ type: 'click', target: item('app-settings') });
menu.dispatchEvent({ type: 'click', target: item('server-settings') });
assert.deepEqual(opened, ['open', 'open', 'admin']);
assert.doesNotMatch(ChromeLayout.CSS, /sidebar-settings-panel/);
assert.doesNotMatch(ChromeLayout.CSS, /#update-banner/, 'admins still see update notices');
plugin.stop();
});
});
test('ChromeLayout shows the core DM badge on the dock button and puts it back', () => {
withEnv(({ document }) => {
const header = document.getElementById('dm-toggle-header');
const badge = document.getElementById('dm-unread-badge');
const plugin = new ChromeLayout();
plugin.start();
const dmBtn = document.getElementById('dm-dock-btn');
assert.equal(badge.parentNode, dmBtn, 'the real badge moves onto the dock button');
assert.equal(document.querySelectorAll('#dm-unread-badge').length, 1);
plugin.stop();
assert.equal(badge.parentNode, header);
assert.equal(header.children.indexOf(badge), 1, 'badge returns to its original slot');
assert.equal(document.getElementById('dm-unread-badge'), badge);
});
});
test('ChromeLayout channel actions open sections through Haven\'s toggles without inline styles', () => {
withEnv(({ document, elementsById }) => {
const make = (id) => {
const el = new FakeElement('div', elementsById);
el.id = id;
document.body.appendChild(el);
return el;
};
const joinBody = make('join-section-body');
joinBody.classList.add('collapsed');
const joinToggle = make('join-section-toggle');
let toggled = 0;
joinToggle.click = () => { toggled++; joinBody.classList.remove('collapsed'); };
const createBody = make('create-section-body');
const createToggle = make('create-section-toggle');
createToggle.click = () => { toggled += 10; };
const adminControls = make('admin-controls');
const plugin = new ChromeLayout();
plugin.start();
plugin._handleChannelAction('join', true);
plugin._handleChannelAction('join', true);
plugin._handleChannelAction('create', true);
assert.equal(toggled, 1, 'only the collapsed Join section is toggled, once');
for (const el of [joinBody, createBody, adminControls]) {
assert.equal(el.style.display, undefined, `${el.id} keeps its inline display`);
}
plugin.stop();
});
});
test('ChromeLayout People dock button uses Haven\'s panel toggle', () => {
withEnv(({ document, elementsById }) => {
const right = new FakeElement('aside', elementsById);
right.id = 'right-sidebar';
const toggle = new FakeElement('button', elementsById);
toggle.id = 'sidebar-toggle-btn';
document.body.append(right, toggle);
let coreToggles = 0;
toggle.click = () => {
// Haven's own handler runs first, then the layout's listener syncs.
coreToggles++;
right.classList.toggle('collapsed');
toggle.dispatchEvent({ type: 'click' });
};
const plugin = new ChromeLayout();
plugin.start();
const peopleBtn = document.getElementById('people-dock-btn');
assert.equal(peopleBtn.getAttribute('aria-pressed'), 'true');
peopleBtn.dispatchEvent({ type: 'click', preventDefault() {}, stopPropagation() {} });
assert.equal(coreToggles, 1);
assert.equal(right.classList.contains('collapsed'), true);
assert.equal(peopleBtn.getAttribute('aria-pressed'), 'false');
// Toggled from Haven's own button: the dock follows.
toggle.click();
assert.equal(peopleBtn.getAttribute('aria-pressed'), 'true');
plugin.stop();
assert.equal(toggle.listeners.get('click')?.size || 0, 0, 'listener removed on stop');
});
});
test('ChromeLayout does not listen for the retired haven:layout-effect event', () => {
const source = fs.readFileSync(path.join(ROOT, 'plugins/ChromeLayout.plugin.js'), 'utf8');
assert.doesNotMatch(source, /haven:layout-effect/);
assert.doesNotMatch(source, /_togglePeoplePanel|_openSettingsModal/);
});
test('ChromeLayout menu strings use Haven translations and fall back to English', () => {
withEnv(({ document }) => {
window.t = (key) => (key === 'app.sidebar.add_server' ? 'Servidor' : key);
const plugin = new ChromeLayout();
plugin.start();
const home = document.getElementById('home-server-menu');
const label = (action) => home.children.find(b => b.dataset.homeAction === action).textContent;
assert.match(label('add-server'), /Servidor$/);
assert.match(label('manage-servers'), /Manage Servers$/, 'a missing key falls back to English');
const channelMenu = document.getElementById('channel-actions-menu');
assert.equal(channelMenu.children.find(b => b.dataset.action === 'join').textContent, 'Join channel');
plugin.stop();
});
});