'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const BraidLayout = require('../plugins/BraidLayout.plugin.js'); const CompactLayout = require('../plugins/CompactLayout.plugin.js'); const ModMode = require('../public/js/modmode.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) { super(); this.name = name; this.parentNode = null; this.children = []; this.attributes = new Map(); this.dataset = {}; this.style = new FakeStyle(); const classes = new Set(); this.classList = { add(...names) { names.forEach(name => classes.add(name)); }, remove(...names) { names.forEach(name => classes.delete(name)); }, contains(name) { return classes.has(name); }, toggle(name, force) { const enabled = force === undefined ? !classes.has(name) : Boolean(force); if (enabled) classes.add(name); else classes.delete(name); return enabled; } }; this.textContent = ''; this.title = ''; this.type = ''; } 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); } 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.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)); } removeAttribute(name) { this.attributes.delete(name); } hasAttribute(name) { return this.attributes.has(name); } getAttribute(name) { return this.attributes.get(name) ?? null; } } class FakeMediaQuery extends FakeTarget { constructor(matches) { super(); this.matches = matches; } setMatches(matches) { this.matches = matches; this.dispatchEvent({ type: 'change', matches }); } } function createEnvironment(desktop = true) { const document = new FakeTarget(); document.documentElement = new FakeElement('html'); document.body = new FakeElement('body'); document.createElement = name => new FakeElement(name); document.getElementById = () => null; document.querySelector = () => null; document.querySelectorAll = () => []; const regions = new Map(); const region = name => { const element = new FakeElement(name); regions.set(name, element); return element; }; const workspace = region('workspace'); const serverRail = region('server-rail'); const serverList = region('server-list'); const navigation = region('navigation-sidebar'); const navigationHeader = new FakeElement('navigation-header'); const sidebarContent = region('sidebar-content'); const footer = region('sidebar-footer'); const sidebarActions = region('sidebar-actions'); const account = region('account'); const main = region('main'); const context = region('context-sidebar'); const voiceRoster = region('voice-roster'); const memberList = region('member-list'); const voiceSettings = region('voice-settings'); const voiceControls = region('voice-controls'); const footerDecoration = new FakeElement('footer-decoration'); const contextDecoration = new FakeElement('context-decoration'); serverRail.append(serverList); navigationHeader.append(account); footer.append(footerDecoration, sidebarActions); navigation.append(navigationHeader, sidebarContent, footer); context.append(voiceRoster, memberList, voiceSettings, voiceControls, contextDecoration); workspace.append(serverRail, navigation, main, context); const media = new FakeMediaQuery(desktop); const styles = new Map(); const data = new Map(); let layoutOwner = null; let reservedLayoutOwner = null; const HavenApi = { DOM: { addStyle(id, css) { styles.set(id, css); }, removeStyle(id) { styles.delete(id); }, query(selector) { const name = selector.match(/^\[data-haven-region="([a-z-]+)"\]$/)?.[1]; return regions.get(name) || null; } }, Layout: { acquire(owner) { if ((reservedLayoutOwner && reservedLayoutOwner !== owner) || (layoutOwner && layoutOwner !== owner)) return false; if (layoutOwner === owner) return true; 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; }, _reserve(owner) { reservedLayoutOwner = owner || null; }, _clearReservation(owner) { if (reservedLayoutOwner !== (owner || null)) return false; reservedLayoutOwner = null; return true; }, get owner() { return layoutOwner; } }, Data: { save(plugin, key, value) { data.set(`${plugin}:${key}`, value); }, load(plugin, key, fallback) { return data.get(`${plugin}:${key}`) ?? fallback; } }, UI: { showToast() {} } }; return { document, HavenApi, media, styles, data, nodes: { workspace, serverRail, navigation, navigationHeader, sidebarContent, footer, sidebarActions, account, main, context, voiceRoster, memberList, voiceSettings, voiceControls, footerDecoration, contextDecoration } }; } function withEnvironment(desktop, callback) { const previous = { document: global.document, window: global.window, HavenApi: global.HavenApi }; const environment = createEnvironment(desktop); global.document = environment.document; global.window = { HavenApi: environment.HavenApi, matchMedia: () => environment.media }; global.HavenApi = environment.HavenApi; try { callback(environment); } finally { global.document = previous.document; global.window = previous.window; global.HavenApi = previous.HavenApi; } } function withBraidEnvironment(callback) { const previous = { localStorage: global.localStorage, MutationObserver: global.MutationObserver, requestAnimationFrame: global.requestAnimationFrame }; const storage = new Map(); global.localStorage = { getItem(key) { return storage.get(key) ?? null; }, setItem(key, value) { storage.set(key, String(value)); }, removeItem(key) { storage.delete(key); } }; global.MutationObserver = class MutationObserver { observe() {} disconnect() {} }; global.requestAnimationFrame = () => 1; try { withEnvironment(true, environment => callback(environment)); } finally { global.localStorage = previous.localStorage; global.MutationObserver = previous.MutationObserver; global.requestAnimationFrame = previous.requestAnimationFrame; } } function stubBraidVisuals(plugin) { for (const method of [ '_buildReturnPill', '_paintOwn', '_themeBottomIcons', '_applyTextScales', '_collapseJoinCreate', '_setPeopleOpen', '_buildMoreMenu', '_applyLayout', '_showModDone', '_unfoldVoiceDock', '_restoreBottomIcons' ]) plugin[method] = () => {}; } test('Compact Layout uses only public regions and scoped CSS', () => { const source = fs.readFileSync(path.join(ROOT, 'plugins/CompactLayout.plugin.js'), 'utf8'); const publicRegions = new Set([ 'server-rail', 'navigation-sidebar', 'account', 'sidebar-footer', 'sidebar-actions', 'voice-settings', 'voice-controls' ]); assert.match(source, /@name Compact Layout/); assert.match(source, /class CompactLayout/); assert.doesNotMatch(source, /getElementById|querySelector(?:All)?/); assert.equal((CompactLayout.CSS.match(/!important/g) || []).length, 2); assert.doesNotMatch(CompactLayout.CSS, /--msg-/); assert.doesNotMatch(CompactLayout.CSS, /(?:^|[\s,{>+~])[.#][a-z_-]/im); assert.match(CompactLayout.CSS, /data-compact-layout-desktop/); for (const region of Object.values(CompactLayout.REGIONS)) { assert.ok(publicRegions.has(region), `Compact Layout uses non-public region ${region}`); } }); test('Compact Layout moves desktop regions and restores their exact positions on stop', () => { withEnvironment(true, ({ document, styles, nodes }) => { const originalWorkspace = [...nodes.workspace.children]; const originalHeader = [...nodes.navigationHeader.children]; const originalFooter = [...nodes.footer.children]; const originalContext = [...nodes.context.children]; const plugin = new CompactLayout(); plugin.start(); assert.equal(document.documentElement.getAttribute('data-compact-layout'), '1'); assert.equal(document.documentElement.getAttribute('data-compact-layout-desktop'), '1'); assert.equal(nodes.navigation.firstChild, nodes.serverRail); assert.deepEqual(nodes.footer.children.slice(-4), [ nodes.voiceSettings, nodes.voiceControls, nodes.account, nodes.sidebarActions ]); assert.ok(styles.has('CompactLayout')); assert.equal(plugin._control.parentNode, nodes.sidebarActions); assert.equal(plugin._control.getAttribute('aria-pressed'), 'true'); assert.equal(document.documentElement.getAttribute('data-haven-layout-owner'), 'CompactLayout'); plugin.stop(); assert.deepEqual(nodes.workspace.children, originalWorkspace); assert.deepEqual(nodes.navigationHeader.children, originalHeader); assert.deepEqual(nodes.footer.children, originalFooter); assert.deepEqual(nodes.context.children, originalContext); assert.equal(document.documentElement.hasAttribute('data-compact-layout'), false); assert.equal(document.documentElement.hasAttribute('data-compact-layout-desktop'), false); assert.equal(document.documentElement.hasAttribute('data-haven-layout-owner'), false); assert.equal(styles.has('CompactLayout'), false); }); }); test('Compact Layout restores native layout for responsive and Mod Mode states', () => { withEnvironment(true, ({ document, media, data, nodes }) => { const originalWorkspace = [...nodes.workspace.children]; const originalContext = [...nodes.context.children]; nodes.voiceControls.style.setProperty('position', 'fixed', 'important'); nodes.voiceControls.style.setProperty('bottom', '80px'); nodes.voiceControls.style.setProperty('border', '1px solid red'); nodes.voiceControls.style.setProperty('overflow', 'scroll'); nodes.voiceControls.style.setProperty('resize', 'both'); const plugin = new CompactLayout(); plugin.start(); assert.equal(nodes.voiceControls.style.getPropertyValue('position'), 'static'); assert.equal(nodes.voiceControls.style.getPropertyPriority('position'), 'important'); assert.equal(nodes.voiceControls.style.getPropertyValue('border'), '0'); assert.equal(nodes.voiceControls.style.getPropertyPriority('border'), 'important'); assert.equal(nodes.voiceControls.style.getPropertyValue('overflow'), 'auto hidden'); assert.equal(nodes.voiceControls.style.getPropertyValue('resize'), 'none'); media.setMatches(false); assert.deepEqual(nodes.workspace.children, originalWorkspace); assert.deepEqual(nodes.context.children, originalContext); assert.equal(document.documentElement.hasAttribute('data-compact-layout-desktop'), false); assert.equal(document.documentElement.getAttribute('data-compact-layout'), '1'); assert.equal(nodes.voiceControls.style.getPropertyValue('position'), 'fixed'); assert.equal(nodes.voiceControls.style.getPropertyPriority('position'), 'important'); assert.equal(nodes.voiceControls.style.getPropertyValue('bottom'), '80px'); assert.equal(nodes.voiceControls.style.getPropertyValue('border'), '1px solid red'); assert.equal(nodes.voiceControls.style.getPropertyValue('overflow'), 'scroll'); assert.equal(nodes.voiceControls.style.getPropertyValue('resize'), 'both'); media.setMatches(true); assert.equal(document.documentElement.getAttribute('data-compact-layout-desktop'), '1'); document.dispatchEvent({ type: 'haven:layout-editing', detail: { active: true } }); assert.deepEqual(nodes.workspace.children, originalWorkspace); assert.deepEqual(nodes.context.children, originalContext); document.dispatchEvent({ type: 'haven:layout-editing', detail: { active: false } }); assert.equal(document.documentElement.getAttribute('data-compact-layout-desktop'), '1'); plugin._control.dispatchEvent({ type: 'click' }); assert.equal(document.documentElement.hasAttribute('data-compact-layout'), false); assert.equal(data.get('CompactLayout:layoutOn'), '0'); let prevented = false; document.dispatchEvent({ type: 'keydown', ctrlKey: true, altKey: true, key: 'c', preventDefault() { prevented = true; } }); assert.equal(prevented, true); assert.equal(data.get('CompactLayout:layoutOn'), '1'); assert.equal(document.documentElement.getAttribute('data-compact-layout-desktop'), '1'); plugin.stop(); }); }); test('Compact Layout waits for another structural layout owner and retries after release', () => { withEnvironment(true, ({ document, HavenApi, nodes }) => { const originalWorkspace = [...nodes.workspace.children]; assert.equal(HavenApi.Layout.acquire('BraidLayout'), true); const plugin = new CompactLayout(); plugin.start(); assert.deepEqual(nodes.workspace.children, originalWorkspace); assert.equal(document.documentElement.hasAttribute('data-compact-layout-desktop'), false); assert.equal(plugin._control.getAttribute('aria-label'), 'Compact layout waiting for another layout plugin'); HavenApi.Layout.release('BraidLayout'); assert.equal(document.documentElement.getAttribute('data-haven-layout-owner'), 'CompactLayout'); assert.equal(document.documentElement.getAttribute('data-compact-layout-desktop'), '1'); assert.equal(nodes.navigation.firstChild, nodes.serverRail); plugin.stop(); }); }); test('Mod Mode restores the previous layout owner regardless of listener order', () => { withBraidEnvironment(({ document, HavenApi }) => { const compact = new CompactLayout(); compact.start(); const braid = new BraidLayout(); stubBraidVisuals(braid); braid.start(); compact._control.dispatchEvent({ type: 'click' }); assert.equal(HavenApi.Layout.owner, 'BraidLayout'); compact._control.dispatchEvent({ type: 'click' }); assert.equal(compact._blocked, true); document.documentElement.setAttribute('data-haven-layout-editing', '1'); document.dispatchEvent({ type: 'haven:layout-editing', detail: { active: true, owner: 'BraidLayout' } }); assert.equal(HavenApi.Layout.owner, null); document.documentElement.removeAttribute('data-haven-layout-editing'); document.dispatchEvent({ type: 'haven:layout-editing', detail: { active: false, owner: 'BraidLayout' } }); assert.equal(HavenApi.Layout.owner, 'BraidLayout'); assert.equal(braid._suspended, false); assert.equal(compact._blocked, true); compact.stop(); braid.stop(); }); }); test('Mod Mode reserves the previous owner against legacy layout listeners', () => { const previousCustomEvent = global.CustomEvent; global.CustomEvent = class CustomEvent { constructor(type, init) { this.type = type; this.detail = init?.detail; } }; try { withBraidEnvironment(({ document, HavenApi }) => { let legacyAcquired = null; document.addEventListener('haven:layout-editing', event => { if (event.detail?.active === false) { legacyAcquired = HavenApi.Layout.acquire('LegacyLayout'); } }); const braid = new BraidLayout(); stubBraidVisuals(braid); braid.start(); const modMode = new ModMode(); modMode._beginLayoutEditing(); assert.equal(HavenApi.Layout.owner, null); modMode._endLayoutEditing(); assert.equal(legacyAcquired, false); assert.equal(HavenApi.Layout.owner, 'BraidLayout'); assert.equal(braid._suspended, false); braid.stop(); }); } finally { global.CustomEvent = previousCustomEvent; } }); test('Mod Mode reset suspends and restores an active structural layout', () => { const previous = { CustomEvent: global.CustomEvent, localStorage: global.localStorage, t: global.t }; global.CustomEvent = class CustomEvent { constructor(type, init) { this.type = type; this.detail = init?.detail; } }; global.localStorage = { removeItem() {} }; global.t = key => key; try { withEnvironment(true, ({ document, HavenApi, nodes }) => { const originalWorkspace = [...nodes.workspace.children]; const compact = new CompactLayout(); compact.start(); const modMode = new ModMode(); modMode.container = new FakeElement('mod-container'); modMode.container.querySelectorAll = () => []; modMode._showToast = () => {}; const editingEvents = []; document.addEventListener('haven:layout-editing', event => editingEvents.push(event.detail)); modMode.resetLayout(); assert.deepEqual(editingEvents, [ { active: true, owner: 'CompactLayout' }, { active: false, owner: 'CompactLayout' } ]); assert.equal(HavenApi.Layout.owner, 'CompactLayout'); assert.equal(document.documentElement.getAttribute('data-compact-layout-desktop'), '1'); assert.notDeepEqual(nodes.workspace.children, originalWorkspace); compact.stop(); }); } finally { global.CustomEvent = previous.CustomEvent; global.localStorage = previous.localStorage; global.t = previous.t; } }); test('Compact Layout rolls back ownership and DOM when persistence fails', () => { withEnvironment(true, ({ document, HavenApi, data, nodes }) => { const originalWorkspace = [...nodes.workspace.children]; const originalHeader = [...nodes.navigationHeader.children]; const originalFooter = [...nodes.footer.children]; const originalContext = [...nodes.context.children]; data.set('CompactLayout:layoutOn', '0'); const plugin = new CompactLayout(); plugin.start(); HavenApi.Data.save = () => { throw new Error('storage full'); }; assert.throws(() => plugin._control.dispatchEvent({ type: 'click' }), /storage full/); assert.equal(plugin._engaged, false); assert.equal(HavenApi.Layout.owner, null); assert.deepEqual(nodes.workspace.children, originalWorkspace); assert.deepEqual(nodes.navigationHeader.children, originalHeader); assert.deepEqual(nodes.footer.children, originalFooter); assert.deepEqual(nodes.context.children, originalContext); assert.equal(document.documentElement.hasAttribute('data-compact-layout'), false); assert.equal(document.documentElement.hasAttribute('data-compact-layout-desktop'), false); plugin.stop(); }); }); test('Compact Layout cleans partial startup when persisted data cannot load', () => { withEnvironment(true, ({ document, HavenApi, styles, nodes }) => { const originalActions = [...nodes.sidebarActions.children]; HavenApi.Data.load = () => { throw new Error('corrupt storage'); }; const plugin = new CompactLayout(); assert.throws(() => plugin.start(), /corrupt storage/); assert.equal(plugin._started, false); assert.equal(plugin._control, null); assert.deepEqual(plugin._listeners, []); assert.deepEqual(nodes.sidebarActions.children, originalActions); assert.equal(styles.has('CompactLayout'), false); assert.equal(HavenApi.Layout.owner, null); assert.equal(document.documentElement.hasAttribute('data-compact-layout'), false); }); }); test('Braid releases ownership for Mod Mode and resumes after editing finishes', () => { withBraidEnvironment(({ document, HavenApi }) => { const plugin = new BraidLayout(); stubBraidVisuals(plugin); plugin.start(); const compact = new CompactLayout(); compact.start(); assert.equal(HavenApi.Layout.owner, 'BraidLayout'); assert.equal(document.documentElement.getAttribute('data-braid-layout'), '1'); assert.equal(document.documentElement.hasAttribute('data-compact-layout-desktop'), false); 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-braid-layout'), false); compact.stop(); 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, 'BraidLayout'); assert.equal(document.documentElement.getAttribute('data-braid-layout'), '1'); plugin.stop(); assert.equal(HavenApi.Layout.owner, null); }); }); test('Braid rolls back ownership when engagement persistence fails', () => { withBraidEnvironment(({ document, HavenApi }) => { const plugin = new BraidLayout(); stubBraidVisuals(plugin); const save = HavenApi.Data.save; HavenApi.Data.save = () => { throw new Error('storage full'); }; assert.throws(() => plugin.start(), /storage full/); assert.equal(plugin._engaged, false); assert.equal(HavenApi.Layout.owner, null); assert.equal(document.documentElement.hasAttribute('data-haven-layout-owner'), false); HavenApi.Data.save = save; plugin.stop(); }); }); test('public density and layout-editing hooks are wired into core state changes', () => { const themeInit = fs.readFileSync(path.join(ROOT, 'public/js/theme-init.js'), 'utf8'); const media = fs.readFileSync(path.join(ROOT, 'public/js/modules/app-appearance.js'), 'utf8'); const modMode = fs.readFileSync(path.join(ROOT, 'public/js/modmode.js'), 'utf8'); const pluginLoader = fs.readFileSync(path.join(ROOT, 'public/js/plugin-loader.js'), 'utf8'); const braid = fs.readFileSync(path.join(ROOT, 'plugins/BraidLayout.plugin.js'), 'utf8'); assert.match(themeInit, /data-haven-density/); // Set directly, or through the shared picker helper's key list (#5582). assert.match(media, /dataset\.havenDensity|['"]havenDensity['"]/); assert.match(media, /haven:density-change/); assert.match(modMode, /data-haven-layout-editing/); assert.match(modMode, /haven:layout-editing/); assert.match(modMode, /finally\s*\{\s*this\._endLayoutEditing\(\)/); assert.match(modMode, /_endLayoutEditing\(\)[\s\S]*?removeAttribute\('data-haven-layout-editing'\)[\s\S]*?active:\s*false/); assert.match(pluginLoader, /data-haven-layout-owner/); assert.match(pluginLoader, /haven:layout-owner-change/); assert.match(braid, /Layout\.acquire\('BraidLayout'\)/); assert.match(braid, /Layout\?\.release\('BraidLayout'\)/); });