import test, { beforeEach, afterEach } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFile } from 'node:fs/promises'; import { JSDOM } from 'jsdom'; import ProfileModalController from '../js/ui/profileModalController.js'; import { createWatchHistoryRenderer } from '../js/historyView.js'; import watchHistoryService from '../js/watchHistoryService.js'; import { formatShortNpub } from '../js/utils/formatters.js'; import { resetRuntimeFlags } from '../js/constants.js'; import { applyDesignSystemAttributes } from '../js/designSystem.js'; const profileModalHtml = await readFile( new URL('../components/profile-modal.html', import.meta.url), 'utf8', ); const defaultActorNpub = 'npub1actor0exampleexampleexampleexampleexampleex'; const defaultActorHex = 'a'.repeat(64); let dom; let container; let nativeGetComputedStyle; async function waitForAnimationFrame(window, cycles = 1) { for (let i = 0; i < cycles; i += 1) { await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); } } function createDeferred() { let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject, }; } function createMatchMediaList(query, initialMatches = false) { let matches = Boolean(initialMatches); const listeners = new Set(); const notify = () => { const event = { matches, media: query, type: 'change' }; listeners.forEach((listener) => { try { listener(event); } catch {} }); }; return { get matches() { return matches; }, set matches(value) { matches = Boolean(value); }, media: query, addEventListener(type, listener) { if (type === 'change' && typeof listener === 'function') { listeners.add(listener); } }, removeEventListener(type, listener) { if (type === 'change' && typeof listener === 'function') { listeners.delete(listener); } }, addListener(listener) { if (typeof listener === 'function') { listeners.add(listener); } }, removeListener(listener) { if (typeof listener === 'function') { listeners.delete(listener); } }, dispatchEvent(event) { if (!event || event.type !== 'change') { return true; } listeners.forEach((listener) => { try { listener(event); } catch {} }); return true; }, setMatches(value) { matches = Boolean(value); notify(); }, }; } beforeEach(() => { dom = new JSDOM( '
', { url: 'https://example.com', pretendToBeVisual: true, }, ); global.window = dom.window; global.document = dom.window.document; global.HTMLElement = dom.window.HTMLElement; global.HTMLImageElement = dom.window.HTMLImageElement; global.HTMLInputElement = dom.window.HTMLInputElement; global.HTMLButtonElement = dom.window.HTMLButtonElement; global.Node = dom.window.Node; global.CustomEvent = dom.window.CustomEvent; global.Event = dom.window.Event; global.KeyboardEvent = dom.window.KeyboardEvent; global.MutationObserver = dom.window.MutationObserver; window.requestAnimationFrame = (callback) => callback(0); window.cancelAnimationFrame = () => {}; global.requestAnimationFrame = window.requestAnimationFrame; global.cancelAnimationFrame = window.cancelAnimationFrame; // This fixture loads markup without application stylesheets. Virtualize the // two computed properties consumed by the focus trap rather than asking JSDOM // to resolve every CSS property for every control on each pane refresh. const focusStyles = document.createElement('style'); focusStyles.textContent = ` .focus-test-display-none { display: none; } .focus-test-visibility-hidden { visibility: hidden; } `; document.head.appendChild(focusStyles); nativeGetComputedStyle = window.getComputedStyle.bind(window); window.getComputedStyle = (element) => ({ display: element.matches('.focus-test-display-none, [hidden], input[type="hidden"]') ? 'none' : 'block', visibility: element.closest('.focus-test-visibility-hidden') ? 'hidden' : 'visible', }); window.confirm = () => true; global.confirm = window.confirm; window.__matchMediaMocks = []; window.matchMedia = (query) => { const mql = createMatchMediaList(query, false); window.__matchMediaMocks.push(mql); return mql; }; global.matchMedia = window.matchMedia; container = document.getElementById('modalMount'); global.fetch = async (resource) => { if ( typeof resource === 'string' && resource.includes('components/profile-modal.html') ) { return { ok: true, status: 200, text: async () => profileModalHtml, }; } throw new Error(`Unexpected fetch: ${resource}`); }; }); afterEach(() => { delete global.fetch; delete global.confirm; delete global.requestAnimationFrame; delete global.cancelAnimationFrame; delete global.MutationObserver; delete global.KeyboardEvent; delete global.Event; delete global.CustomEvent; delete global.HTMLButtonElement; delete global.HTMLInputElement; delete global.HTMLImageElement; delete global.Node; delete global.HTMLElement; delete global.document; delete global.window; delete global.matchMedia; if (dom) { dom.window.close(); dom = null; } if (typeof window !== 'undefined') { delete window.matchMedia; delete window.__matchMediaMocks; } container = null; nativeGetComputedStyle = null; }); function normalizeTestTag(value) { if (typeof value !== 'string') { return ''; } const trimmed = value.trim(); if (!trimmed) { return ''; } return trimmed.replace(/^#+/, '').toLowerCase(); } function createController(options = {}) { const { services: serviceOverrides = {}, callbacks = {}, createWatchHistoryRenderer = options.createWatchHistoryRenderer ?? null, } = options; const { relayManager: relayManagerOverrides = {}, userBlocks: userBlocksOverrides = {}, accessControl: accessControlOverrides = {}, nostrClient: nostrClientOverrides = {}, hashtagPreferences: hashtagPreferencesOverrides = {}, ...otherServices } = serviceOverrides; const baseRelayManager = { getEntries: () => [], snapshot: () => [], addRelay: () => ({ changed: true }), removeRelay: () => ({ changed: true }), restoreDefaults: () => ({ changed: true }), cycleRelayMode: () => ({ changed: true }), publishRelayList: async () => ({ ok: true }), setEntries: () => {}, }; const baseUserBlocks = { ensureLoaded: async () => {}, isBlocked: () => false, addBlock: async () => ({ ok: true }), removeBlock: async () => ({ ok: true }), getBlockedPubkeys: () => [], }; const hashtagStore = { interests: new Set(), disinterests: new Set(), }; const baseHashtagPreferences = { getInterests: () => Array.from(hashtagStore.interests).sort(), getDisinterests: () => Array.from(hashtagStore.disinterests).sort(), addInterest: (tag) => { const normalized = normalizeTestTag(tag); if (!normalized) { return false; } const moved = hashtagStore.disinterests.delete(normalized); const had = hashtagStore.interests.has(normalized); hashtagStore.interests.add(normalized); return !had || moved; }, removeInterest: (tag) => { const normalized = normalizeTestTag(tag); if (!normalized) { return false; } return hashtagStore.interests.delete(normalized); }, addDisinterest: (tag) => { const normalized = normalizeTestTag(tag); if (!normalized) { return false; } const moved = hashtagStore.interests.delete(normalized); const had = hashtagStore.disinterests.has(normalized); hashtagStore.disinterests.add(normalized); return !had || moved; }, removeDisinterest: (tag) => { const normalized = normalizeTestTag(tag); if (!normalized) { return false; } return hashtagStore.disinterests.delete(normalized); }, load: async () => {}, publish: async () => ({ ok: true }), load: async () => {}, on: () => () => {}, load: async () => {}, }; const hashtagPreferences = { ...baseHashtagPreferences, ...hashtagPreferencesOverrides, }; const baseAccessControl = { ensureReady: async () => {}, canEditAdminLists: () => false, isSuperAdmin: () => false, getEditors: () => [], getWhitelist: () => [], getBlacklist: () => [], addModerator: async () => ({ ok: true }), removeModerator: async () => ({ ok: true }), addToWhitelist: async () => ({ ok: true }), removeFromWhitelist: async () => ({ ok: true }), addToBlacklist: async () => ({ ok: true }), removeFromBlacklist: async () => ({ ok: true }), }; const mergedServices = { relayManager: { ...baseRelayManager, ...relayManagerOverrides }, userBlocks: { ...baseUserBlocks, ...userBlocksOverrides }, accessControl: { ...baseAccessControl, ...accessControlOverrides }, nostrClient: { sessionActor: { pubkey: null }, ...nostrClientOverrides }, hashtagPreferences, getHashtagPreferences: () => ({ interests: hashtagPreferences.getInterests(), disinterests: hashtagPreferences.getDisinterests(), eventId: null, createdAt: null, loaded: true, uiReady: true, dataReady: true, }), describeHashtagPreferencesError: () => '', onAccessControlUpdated: async () => {}, describeAdminError: () => 'Unable to update moderation settings. Please try again.', describeNotificationError: () => '', sendAdminListNotification: async () => ({ ok: true }), getCurrentUserNpub: () => defaultActorNpub, ...otherServices, }; return new ProfileModalController({ modalContainer: container, removeTrackingScripts: () => {}, setGlobalModalState: () => {}, showError: options.showError ?? (() => {}), showSuccess: options.showSuccess ?? (() => {}), showStatus: options.showStatus ?? (() => {}), createWatchHistoryRenderer, callbacks, services: mergedServices, state: options.state ?? {}, constants: options.constants ?? {}, }); } for (const _ of [0]) { test( 'Profile modal Escape closes and restores trigger focus', async (t) => { const controller = createController(); await controller.load(); applyDesignSystemAttributes(document); const trigger = document.createElement('button'); trigger.id = 'profileTrigger'; trigger.textContent = 'Open profile'; document.body.appendChild(trigger); trigger.focus(); await waitForAnimationFrame(window, 1); const cleanup = () => { try { controller.hide({ silent: true }); } catch {} try { trigger.remove(); } catch {} }; let cleanupRan = false; try { await controller.show('account'); await waitForAnimationFrame(window, 2); const focusTrap = controller.focusTrapContainer || controller.profileModalPanel || controller.profileModal || document.getElementById('profileModal'); assert.ok(focusTrap, 'focus trap container should exist'); focusTrap.dispatchEvent( new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true, }), ); await waitForAnimationFrame(window, 2); const modalRoot = document.getElementById('profileModal'); assert.ok(modalRoot?.classList.contains('hidden')); assert.strictEqual(document.activeElement, trigger); } finally { cleanup(); cleanupRan = true; } t.after(() => { if (!cleanupRan) { cleanup(); } resetRuntimeFlags(); }); }, ); test( 'Add profile request suspends focus trap and elevates login modal', async (t) => { const loginDeferred = createDeferred(); const loginModal = document.createElement('div'); loginModal.id = 'loginModal'; loginModal.className = 'bv-modal hidden'; container.appendChild(loginModal); const controller = createController({ services: { requestAddProfileLogin: async ({ triggerElement }) => { assert.strictEqual(triggerElement, controller.addAccountButton); assert.equal(controller.focusTrapSuspended, true); return loginDeferred.promise; }, }, callbacks: { onAddAccount: async () => {}, }, }); let cleanupRan = false; const cleanup = () => { try { loginModal.remove(); } catch {} try { controller.hide({ silent: true }); } catch {} cleanupRan = true; }; try { await controller.load(); applyDesignSystemAttributes(document); await controller.show('account'); await waitForAnimationFrame(window, 1); const loginPromise = controller.handleAddAccountRequest(); await Promise.resolve(); const profileModalRoot = document.getElementById('profileModal') || controller.profileModal; assert.ok(profileModalRoot, 'profile modal root should exist'); assert.equal( profileModalRoot?.dataset.nestedModalActive, 'true', 'profile modal should mark nested modal state', ); assert.equal( profileModalRoot?.getAttribute('aria-hidden'), 'true', 'profile modal should be hidden from assistive tech', ); const panel = profileModalRoot?.querySelector('.bv-modal__panel') || profileModalRoot; assert.ok(panel?.hasAttribute('inert')); assert.strictEqual( container.lastElementChild, loginModal, 'login modal should be last in the container', ); loginDeferred.resolve({ provider: 'test' }); await loginPromise; await waitForAnimationFrame(window, 1); assert.equal(controller.focusTrapSuspended, false); assert.equal( profileModalRoot?.dataset.nestedModalActive, undefined, 'profile modal nested state should clear after login', ); assert.equal( profileModalRoot?.getAttribute('aria-hidden'), 'false', 'profile modal should restore aria-hidden', ); assert.equal(panel?.hasAttribute('inert'), false); } finally { cleanup(); } t.after(() => { if (!cleanupRan) { cleanup(); } resetRuntimeFlags(); }); }, ); test( 'Profile modal navigation buttons toggle active state', async (t) => { const controller = createController(); await controller.load(); applyDesignSystemAttributes(document); let cleanupRan = false; const cleanup = () => { try { controller.hide({ silent: true }); } catch {} cleanupRan = true; }; try { await controller.show('account'); await waitForAnimationFrame(window, 2); const accountButton = document.getElementById('profileNavAccount'); const relaysButton = document.getElementById('profileNavRelays'); const accountPane = document.getElementById('profilePaneAccount'); const relaysPane = document.getElementById('profilePaneRelays'); assert.ok(accountButton && relaysButton); assert.ok(accountPane && relaysPane); controller.selectPane('relays'); await waitForAnimationFrame(window, 1); assert.equal(relaysButton?.getAttribute('aria-selected'), 'true'); assert.equal(accountButton?.getAttribute('aria-selected'), 'false'); assert.equal(relaysPane?.classList.contains('hidden'), false); assert.equal(accountPane?.classList.contains('hidden'), true); controller.selectPane('account'); await waitForAnimationFrame(window, 1); assert.equal(accountButton?.getAttribute('aria-selected'), 'true'); assert.equal(relaysButton?.getAttribute('aria-selected'), 'false'); assert.equal(accountPane?.classList.contains('hidden'), false); assert.equal(relaysPane?.classList.contains('hidden'), true); } finally { cleanup(); } t.after(() => { if (!cleanupRan) { cleanup(); } resetRuntimeFlags(); }); }, ); test('Profile modal toggles mobile menu and pane views', async (t) => { const controller = createController(); await controller.load(); applyDesignSystemAttributes(document); let cleanupRan = false; const cleanup = () => { try { controller.hide({ silent: true }); } catch {} cleanupRan = true; }; try { await controller.show('account'); await waitForAnimationFrame(window, 2); const layout = document.querySelector('[data-profile-layout]'); const menuWrapper = document.querySelector('[data-profile-mobile-menu]'); const paneWrapper = document.querySelector('[data-profile-mobile-pane]'); const backButton = document.getElementById('profileModalBack'); const mobileLockButton = document.getElementById('profileMobileLockNowBtn'); const mobileLogoutButton = document.getElementById('profileMobileLogoutBtn'); const mobileActions = mobileLogoutButton?.closest( '.profile-modal__mobile-actions', ); assert.ok(layout && menuWrapper && paneWrapper && backButton); assert.equal(mobileActions?.getAttribute('aria-label'), 'Session actions'); assert.equal(mobileLockButton?.closest('nav'), null); assert.equal(mobileLogoutButton?.closest('nav'), null); assert.equal(layout?.dataset.mobileView, 'menu'); assert.equal(menuWrapper?.getAttribute('aria-hidden'), 'false'); assert.equal(menuWrapper?.classList.contains('hidden'), false); assert.equal(menuWrapper?.hasAttribute('hidden'), false); assert.equal(paneWrapper?.getAttribute('aria-hidden'), 'true'); assert.equal(paneWrapper?.classList.contains('hidden'), true); assert.equal(paneWrapper?.hasAttribute('hidden'), true); assert.equal(backButton?.classList.contains('hidden'), true); controller.selectPane('relays'); await waitForAnimationFrame(window, 1); assert.equal(layout?.dataset.mobileView, 'pane'); assert.equal(menuWrapper?.getAttribute('aria-hidden'), 'true'); assert.equal(menuWrapper?.classList.contains('hidden'), true); assert.equal(menuWrapper?.hasAttribute('hidden'), true); assert.equal(paneWrapper?.getAttribute('aria-hidden'), 'false'); assert.equal(paneWrapper?.classList.contains('hidden'), false); assert.equal(paneWrapper?.hasAttribute('hidden'), false); assert.equal(backButton?.classList.contains('hidden'), false); backButton?.click(); await waitForAnimationFrame(window, 1); assert.equal(layout?.dataset.mobileView, 'menu'); assert.equal(menuWrapper?.getAttribute('aria-hidden'), 'false'); assert.equal(menuWrapper?.classList.contains('hidden'), false); assert.equal(menuWrapper?.hasAttribute('hidden'), false); assert.equal(paneWrapper?.getAttribute('aria-hidden'), 'true'); assert.equal(paneWrapper?.classList.contains('hidden'), true); assert.equal(paneWrapper?.hasAttribute('hidden'), true); assert.equal(backButton?.classList.contains('hidden'), true); } finally { cleanup(); } t.after(() => { if (!cleanupRan) { cleanup(); } resetRuntimeFlags(); }); }); test('wallet URI input masks persisted values and restores on focus', async (t) => { const controller = createController(); await controller.load(); controller.state.setActivePubkey('a'.repeat(64)); await controller.show('wallet'); const sampleUri = 'nostr+walletconnect://pub1?relay=wss://relay.example.com'; await controller.services.nwcSettings.updateActiveNwcSettings({ nwcUri: sampleUri, defaultZap: 21, }); controller.walletController.refreshWalletPaneState(); assert.ok( controller.walletController.walletUriInput instanceof window.HTMLElement, 'wallet URI input should exist', ); assert.equal(controller.walletController.walletUriInput.value, '*****'); assert.equal(controller.walletController.walletUriInput.dataset.secretValue, sampleUri); assert.equal( controller.walletController.walletDisconnectButton?.classList.contains('hidden'), false, 'disconnect button should remain visible when a URI exists', ); const formValues = controller.walletController.getWalletFormValues(); assert.equal(formValues.uri, sampleUri); controller.walletController.walletUriInput.dispatchEvent(new window.Event('focus')); assert.equal(controller.walletController.walletUriInput.value, sampleUri); controller.walletController.walletUriInput.dispatchEvent(new window.Event('blur')); assert.equal(controller.walletController.walletUriInput.value, '*****'); controller.walletController.walletUriInput.dispatchEvent(new window.Event('focus')); const updatedUri = `${sampleUri}&name=bitvid`; controller.walletController.walletUriInput.value = updatedUri; controller.walletController.walletUriInput.dispatchEvent(new window.Event('input')); controller.walletController.walletUriInput.dispatchEvent(new window.Event('blur')); const updatedValues = controller.walletController.getWalletFormValues(); assert.equal(updatedValues.uri, updatedUri); assert.equal(controller.walletController.walletUriInput.value, '*****'); controller.walletController.walletUriInput.dispatchEvent(new window.Event('focus')); controller.walletController.walletUriInput.value = ''; controller.walletController.walletUriInput.dispatchEvent(new window.Event('input')); controller.walletController.walletUriInput.dispatchEvent(new window.Event('blur')); assert.equal(controller.walletController.walletUriInput.value, ''); assert.equal(controller.walletController.walletUriInput.dataset.secretValue, undefined); t.after(() => { try { controller.hide({ silent: true }); } catch {} }); }); test('Profile modal uses abbreviated npub display', async () => { const sampleProfiles = [ { pubkey: defaultActorHex, npub: 'npub1abcdefghijkmnopqrstuvwxyz1234567890example', name: '', picture: '', providerId: 'nip07', authType: 'nip07', }, { pubkey: 'b'.repeat(64), npub: 'npub1zyxwvutsrqponmlkjihgfedcba1234567890sample', name: '', picture: '', providerId: 'nsec', authType: 'nsec', }, ]; const controller = createController({ services: { formatShortNpub, }, }); await controller.load(); controller.state.setSavedProfiles(sampleProfiles); controller.state.setActivePubkey(sampleProfiles[0].pubkey); controller.renderSavedProfiles(); const expectedActive = formatShortNpub(sampleProfiles[0].npub); assert.equal(controller.profileNpub.textContent, expectedActive); const switcherNpubEl = controller.switcherList.querySelector('.font-mono'); assert.ok(switcherNpubEl, 'switcher should render the secondary profile'); const expectedSwitcher = formatShortNpub(sampleProfiles[1].npub); assert.equal(switcherNpubEl.textContent, expectedSwitcher); }); // #43: the always-visible identity card mirrors the active profile on every // pane (populated by renderSavedProfiles, so it also refreshes on account // switch), hides when logged out, and click-through opens the Account pane. test('identity card shows the acting profile, hides logged out, opens Account', async () => { const sampleProfiles = [ { pubkey: defaultActorHex, npub: 'npub1abcdefghijkmnopqrstuvwxyz1234567890example', name: 'Primary Account', picture: '', providerId: 'nip07', authType: 'nip07', }, ]; const controller = createController({ services: { formatShortNpub, }, }); await controller.load(); controller.state.setSavedProfiles(sampleProfiles); controller.state.setActivePubkey(defaultActorHex); controller.renderSavedProfiles(); assert.ok(controller.identityCard, 'identity card element is cached'); assert.equal( controller.identityCard.classList.contains('hidden'), false, 'visible while a profile is active', ); assert.equal(controller.identityCard.getAttribute('aria-hidden'), 'false'); assert.equal(controller.identityCardName.textContent, 'Primary Account'); controller.identityCard.click(); assert.equal( controller.getActivePane(), 'account', 'click-through jumps to the Account pane', ); controller.state.setActivePubkey(null); controller.renderSavedProfiles(); assert.equal( controller.identityCard.classList.contains('hidden'), true, 'hidden when logged out (no claimed identity)', ); assert.equal(controller.identityCard.getAttribute('aria-hidden'), 'true'); }); test('renderSavedProfiles applies provider metadata', async () => { const sampleProfiles = [ { pubkey: defaultActorHex, npub: 'npub1abcdefghijkmnopqrstuvwxyz1234567890example', name: 'Primary Account', picture: '', providerId: 'nip07', authType: 'nip07', }, { pubkey: 'b'.repeat(64), npub: 'npub1zyxwvutsrqponmlkjihgfedcba1234567890sample', name: 'Backup', picture: '', providerId: 'nsec', authType: 'nsec', }, ]; const controller = createController({ services: { formatShortNpub, }, }); await controller.load(); controller.state.setSavedProfiles(sampleProfiles); controller.state.setActivePubkey(null); controller.renderSavedProfiles(); const buttons = Array.from( controller.switcherList.querySelectorAll('button[data-provider-id]'), ); assert.equal(buttons.length, 2, 'expected two provider entries in the switcher'); const [extensionButton, directKeyButton] = buttons; assert.equal(extensionButton.dataset.providerId, 'nip07'); const extensionLabel = extensionButton.querySelector('[data-provider-variant]'); assert.ok(extensionLabel, 'extension entry should include a provider badge'); assert.equal(extensionLabel.textContent, 'extension (nip-07)'); assert.equal(extensionLabel.dataset.providerVariant, 'info'); assert.equal(extensionButton.getAttribute('aria-pressed'), 'false'); assert.equal(directKeyButton.dataset.providerId, 'nsec'); const directKeyLabel = directKeyButton.querySelector('[data-provider-variant]'); assert.ok(directKeyLabel, 'direct key entry should include a provider badge'); assert.equal( directKeyLabel.textContent, 'nsec or seed (direct private key)', ); assert.equal(directKeyLabel.dataset.providerVariant, 'warning'); assert.equal(directKeyButton.getAttribute('aria-pressed'), 'false'); }); test('Hashtag pane shows empty states by default', async (t) => { const controller = createController(); await controller.load(); applyDesignSystemAttributes(document); let cleanupRan = false; const cleanup = () => { try { controller.hide({ silent: true }); } catch {} cleanupRan = true; }; try { await controller.show('hashtags'); await waitForAnimationFrame(window, 2); const interestList = document.getElementById('profileHashtagInterestList'); const interestEmpty = document.getElementById('profileHashtagInterestEmpty'); const disinterestList = document.getElementById('profileHashtagDisinterestList'); const disinterestEmpty = document.getElementById( 'profileHashtagDisinterestEmpty', ); assert.ok(interestList && interestEmpty && disinterestList && disinterestEmpty); assert.equal(interestList?.classList.contains('hidden'), true); assert.equal(interestEmpty?.classList.contains('hidden'), false); assert.equal(disinterestList?.classList.contains('hidden'), true); assert.equal(disinterestEmpty?.classList.contains('hidden'), false); } finally { cleanup(); } t.after(() => { if (!cleanupRan) { cleanup(); } resetRuntimeFlags(); }); }); test('Hashtag pane adds, moves, and removes tags', async (t) => { const controller = createController(); await controller.load(); applyDesignSystemAttributes(document); let cleanupRan = false; const cleanup = () => { try { controller.hide({ silent: true }); } catch {} cleanupRan = true; }; try { await controller.show('hashtags'); await waitForAnimationFrame(window, 2); controller.hashtagInterestInput.value = '#nostr'; controller.addHashtagInterestButton.click(); await waitForAnimationFrame(window, 2); const interestList = document.getElementById('profileHashtagInterestList'); assert.equal(interestList?.classList.contains('hidden'), false); assert.equal(interestList?.querySelectorAll('li').length, 1); const interestLabel = interestList?.querySelector('li span'); assert.equal(interestLabel?.textContent, '#nostr'); const disinterestList = document.getElementById('profileHashtagDisinterestList'); const disinterestEmpty = document.getElementById('profileHashtagDisinterestEmpty'); assert.equal(disinterestList?.classList.contains('hidden'), true); assert.equal(disinterestEmpty?.classList.contains('hidden'), false); controller.hashtagDisinterestInput.value = '#nostr'; controller.addHashtagDisinterestButton.click(); await waitForAnimationFrame(window, 2); assert.equal(interestList?.querySelectorAll('li').length, 0); const interestEmpty = document.getElementById('profileHashtagInterestEmpty'); assert.equal(interestEmpty?.classList.contains('hidden'), false); assert.equal(disinterestList?.classList.contains('hidden'), false); assert.equal(disinterestList?.querySelectorAll('li').length, 1); const disinterestLabel = disinterestList?.querySelector('li span'); assert.equal(disinterestLabel?.textContent, '#nostr'); const removeButton = disinterestList?.querySelector( 'button.profile-hashtag-remove', ); assert.ok(removeButton); removeButton.click(); await waitForAnimationFrame(window, 2); assert.equal(disinterestList?.querySelectorAll('li').length, 0); assert.equal(disinterestEmpty?.classList.contains('hidden'), false); } finally { cleanup(); } t.after(() => { if (!cleanupRan) { cleanup(); } resetRuntimeFlags(); }); }); test('handleAddHashtagPreference publishes updates', async (t) => { const publishCalls = []; const controller = createController({ services: { hashtagPreferences: { publish: async (payload) => { publishCalls.push(payload); return { ok: true }; }, }, }, }); await controller.load(); applyDesignSystemAttributes(document); controller.state.setActivePubkey(defaultActorHex); let cleanupRan = false; const cleanup = () => { try { controller.hide({ silent: true }); } catch {} cleanupRan = true; }; try { await controller.show('hashtags'); await waitForAnimationFrame(window, 2); controller.hashtagInterestInput.value = '#nostr'; const result = await controller.handleAddHashtagPreference('interest'); assert.equal(result.success, true); assert.equal(publishCalls.length, 1); assert.deepEqual(publishCalls[0], { pubkey: defaultActorHex }); } finally { cleanup(); } t.after(() => { if (!cleanupRan) { cleanup(); } resetRuntimeFlags(); }); }); test('Hashtag pane resets after logout when service clears tags', async (t) => { const controller = createController(); await controller.load(); applyDesignSystemAttributes(document); let cleanupRan = false; const cleanup = () => { try { controller.hide({ silent: true }); } catch {} cleanupRan = true; }; try { await controller.show('hashtags'); await waitForAnimationFrame(window, 2); controller.hashtagInterestInput.value = 'art'; controller.addHashtagInterestButton.click(); await waitForAnimationFrame(window, 2); const interestList = document.getElementById('profileHashtagInterestList'); assert.equal(interestList?.querySelectorAll('li').length, 1); controller.services.hashtagPreferences.removeInterest('art'); await controller.handleAuthLogout({}); await waitForAnimationFrame(window, 2); const interestEmpty = document.getElementById('profileHashtagInterestEmpty'); const disinterestEmpty = document.getElementById('profileHashtagDisinterestEmpty'); assert.equal(interestList?.querySelectorAll('li').length, 0); assert.equal(interestEmpty?.classList.contains('hidden'), false); assert.equal(disinterestEmpty?.classList.contains('hidden'), false); } finally { cleanup(); } t.after(() => { if (!cleanupRan) { cleanup(); } resetRuntimeFlags(); }); }); } test('load() injects markup and caches expected elements', async () => { const controller = createController(); const result = await controller.load(); assert.equal(result, true); const modal = container.querySelector('#profileModal'); assert.ok(modal instanceof window.HTMLElement); assert.strictEqual(controller.profileModal, modal); assert.ok(controller.navButtons.account instanceof window.HTMLElement); assert.ok(controller.panes.account instanceof window.HTMLElement); assert.ok(controller.relayList instanceof window.HTMLElement); assert.ok(controller.blockList instanceof window.HTMLElement); assert.ok(controller.walletController.walletStatusText instanceof window.HTMLElement); }); test('focus trap excludes hidden and disabled controls with fixture and native styles', () => { const panel = document.createElement('div'); container.appendChild(panel); const addControl = (id, configure = () => {}) => { const control = document.createElement('input'); control.id = id; configure(control); panel.appendChild(control); return control; }; const visible = addControl('visible'); addControl('disabled', (control) => { control.disabled = true; }); addControl('display-none', (control) => { control.classList.add('focus-test-display-none'); }); addControl('visibility-hidden', (control) => { control.classList.add('focus-test-visibility-hidden'); }); addControl('aria-hidden', (control) => { control.setAttribute('aria-hidden', 'true'); }); addControl('hidden-input', (control) => { control.type = 'hidden'; }); addControl('hidden-attribute', (control) => { control.hidden = true; }); const inherited = addControl('inherited-visibility'); const hiddenParent = document.createElement('div'); hiddenParent.classList.add('focus-test-visibility-hidden'); panel.appendChild(hiddenParent); hiddenParent.appendChild(inherited); const last = addControl('last'); const controller = createController(); controller.profileModalPanel = panel; const fixtureGetComputedStyle = window.getComputedStyle; try { for (const resolveStyle of [fixtureGetComputedStyle, nativeGetComputedStyle]) { window.getComputedStyle = resolveStyle; controller.updateFocusTrap(); assert.deepEqual(controller.focusableElements.map((control) => control.id), ['visible', 'last']); last.focus(); const tab = new window.KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true, }); last.dispatchEvent(tab); assert.equal(tab.defaultPrevented, true); assert.strictEqual(document.activeElement, visible); } } finally { window.getComputedStyle = fixtureGetComputedStyle; controller.hide({ silent: true }); } }); test('show()/hide() toggle panes, trap focus, and refresh the wallet pane', async () => { const controller = createController(); await controller.load(); let adminRefreshes = 0; controller.refreshAdminPaneState = async () => { adminRefreshes += 1; }; let walletRefreshes = 0; controller.walletController.refreshWalletPaneState = () => { walletRefreshes += 1; }; let relaysPopulated = 0; controller.relayController.populateProfileRelays = () => { relaysPopulated += 1; }; controller.populateBlockedList = () => {}; const focusBefore = document.createElement('button'); focusBefore.id = 'before'; document.body.appendChild(focusBefore); focusBefore.focus(); await controller.show('wallet'); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(adminRefreshes, 1); assert.equal(walletRefreshes, 2); assert.equal(relaysPopulated, 1); assert.equal(controller.getActivePane(), 'wallet'); assert.equal(controller.profileModal.getAttribute('aria-hidden'), 'false'); assert.ok(controller.boundKeydown); assert.ok(controller.focusableElements.length > 0); const first = controller.focusableElements[0]; const last = controller.focusableElements[controller.focusableElements.length - 1]; last.focus(); let prevented = false; controller.boundKeydown({ key: 'Tab', shiftKey: false, preventDefault: () => { prevented = true; }, }); assert.equal(document.activeElement, first); assert.equal(prevented, true); await controller.hide(); assert.equal(controller.profileModal.getAttribute('aria-hidden'), 'true'); assert.equal(controller.getActivePane(), 'account'); }); test('populateProfileRelays renders entries and wires action buttons', async () => { const relays = [ { url: 'wss://relay.one', mode: 'write' }, { url: 'wss://relay.two', read: true, write: false }, ]; const controller = createController({ services: { relayManager: { getEntries: () => relays, }, }, }); await controller.load(); const modeCalls = []; const removeCalls = []; controller.relayController.handleRelayModeToggle = (url) => { modeCalls.push(url); }; controller.relayController.handleRemoveRelay = (url) => { removeCalls.push(url); }; controller.populateProfileRelays(); const items = controller.relayList.querySelectorAll('li'); assert.equal(items.length, relays.length); const firstButtons = items[0].querySelectorAll('button'); assert.equal(firstButtons.length, 2); firstButtons[0].click(); firstButtons[1].click(); assert.deepEqual(modeCalls, ['wss://relay.one']); assert.deepEqual(removeCalls, ['wss://relay.one']); }); test('admin mutations invoke accessControl stubs and update admin DOM', async () => { const successMessages = []; const errorMessages = []; const ensureReadyCalls = []; const accessEvents = []; const accessControl = { ensureReady: async () => { ensureReadyCalls.push('ensure'); }, canEditAdminLists: () => true, isSuperAdmin: () => true, getEditors: () => new Set(['npub1moderator']), getWhitelist: () => new Set(['npub1allow']), getBlacklist: () => new Set(['npub1block']), addModerator: async (actor, target) => { accessEvents.push(['addModerator', actor, target]); return { ok: true }; }, removeModerator: async (actor, target) => { accessEvents.push(['removeModerator', actor, target]); return { ok: true }; }, addToWhitelist: async (actor, target) => { accessEvents.push(['addWhitelist', actor, target]); return { ok: true }; }, removeFromWhitelist: async (actor, target) => { accessEvents.push(['removeWhitelist', actor, target]); return { ok: true }; }, addToBlacklist: async (actor, target) => { accessEvents.push(['addBlacklist', actor, target]); return { ok: true }; }, removeFromBlacklist: async (actor, target) => { accessEvents.push(['removeBlacklist', actor, target]); return { ok: true }; }, }; const onAccessControlUpdatedCalls = []; const controller = createController({ showSuccess: (message) => successMessages.push(message), showError: (message) => errorMessages.push(message), services: { accessControl, getCurrentUserNpub: () => defaultActorNpub, onAccessControlUpdated: async () => { onAccessControlUpdatedCalls.push('updated'); }, }, }); await controller.load(); await controller.refreshAdminPaneState(); assert.ok(controller.navButtons.admin); assert.equal(controller.navButtons.admin.classList.contains('hidden'), false); // The admin pane is now organized into sub-tabs: Whitelist is the default tab // (visible), while Moderators/Blacklist/Blocked-videos sections are hidden // until their tab is selected. The Moderators *tab button* is shown to a // super admin. (Previously every section was stacked and visible at once.) const admin = controller.adminController; assert.equal(admin.whitelistSection.classList.contains('hidden'), false); assert.equal(admin.moderatorSection.classList.contains('hidden'), true); assert.equal( admin.subtabButtons.moderators.classList.contains('hidden'), false, 'moderators sub-tab is available to a super admin', ); // Content still populates behind the inactive tab. assert.equal(controller.adminModeratorList.querySelectorAll('li').length, 1); // Selecting the Moderators sub-tab reveals its section and hides Whitelist. admin.selectAdminSubtab('moderators'); assert.equal(admin.moderatorSection.classList.contains('hidden'), false); assert.equal(admin.whitelistSection.classList.contains('hidden'), true); // Back to Whitelist for the remaining assertions. admin.selectAdminSubtab('whitelist'); assert.equal( controller.whitelistList.querySelectorAll('button').length > 0, true, ); assert.equal(controller.whitelistList.classList.contains('hidden'), false); assert.equal(controller.whitelistList.hasAttribute('hidden'), false); controller.moderatorInput.value = 'npub1newmoderator'; await controller.handleAddModerator(); assert.deepEqual(accessEvents[0], [ 'addModerator', defaultActorNpub, 'npub1newmoderator', ]); assert.equal(controller.addModeratorButton.disabled, false); assert.equal( controller.addModeratorButton.hasAttribute('aria-busy'), false, ); assert.equal(controller.moderatorInput.value, ''); assert.ok(onAccessControlUpdatedCalls.length >= 1); assert.ok(successMessages.includes('Moderator added successfully.')); controller.whitelistInput.value = 'npub1fresh'; const whitelistButton = controller.addWhitelistButton; await controller.handleAdminListMutation('whitelist', 'add'); assert.deepEqual(accessEvents[1], [ 'addWhitelist', defaultActorNpub, 'npub1fresh', ]); assert.equal(whitelistButton.disabled, false); assert.equal(whitelistButton.hasAttribute('aria-busy'), false); assert.ok(successMessages.includes('Added to the whitelist.')); const removeButton = controller.whitelistList.querySelector( 'button[data-role="remove"]', ); await controller.handleAdminListMutation('whitelist', 'remove', 'npub1allow', removeButton); const removeEvent = accessEvents.find((entry) => entry[0] === 'removeWhitelist'); assert.deepEqual(removeEvent, [ 'removeWhitelist', defaultActorNpub, 'npub1allow', ]); assert.equal(removeButton.disabled, false); assert.equal(removeButton.hasAttribute('aria-busy'), false); assert.ok(successMessages.includes('Removed from the whitelist.')); assert.deepEqual(errorMessages, []); assert.ok(ensureReadyCalls.length >= 3); }); test('history pane lazily initializes the watch history renderer', async () => { const historyCalls = []; let capturedConfig = null; let isInitialized = false; const fakeRenderer = { getState: () => ({ initialized: isInitialized }), ensureInitialLoad: async ({ actor }) => { historyCalls.push(['ensure', actor]); isInitialized = true; }, refresh: async ({ actor, force }) => { historyCalls.push(['refresh', actor, force]); }, resume: () => { historyCalls.push(['resume']); }, pause: () => { historyCalls.push(['pause']); }, destroy: () => { historyCalls.push(['destroy']); isInitialized = false; }, }; const controller = createController({ createWatchHistoryRenderer: (config) => { capturedConfig = config; return fakeRenderer; }, services: { nostrClient: { sessionActor: { pubkey: defaultActorHex } }, getCurrentUserNpub: () => defaultActorNpub, }, }); await controller.load(); controller.refreshAdminPaneState = async () => {}; controller.populateProfileRelays = () => {}; controller.populateBlockedList = () => {}; controller.refreshWalletPaneState = () => {}; // First show: Should call ensureInitialLoad but NOT refresh (optimized) controller.setActivePubkey(defaultActorHex); await controller.show('history'); await new Promise((resolve) => setTimeout(resolve, 0)); await Promise.resolve(); assert.ok(capturedConfig); assert.equal(capturedConfig.viewSelector, '#profilePaneHistory'); // Expect ensure + resume, NO refresh on first load assert.deepEqual(historyCalls.slice(0, 2), [ ['ensure', defaultActorHex], ['resume'], ]); assert.ok(typeof controller.boundProfileHistoryVisibility === 'function'); // Reset calls for second pass while (historyCalls.length) historyCalls.pop(); // Switch to another pane (should pause but NOT destroy) controller.selectPane('account'); await Promise.resolve(); assert.equal(historyCalls.length > 0, true, 'Should have recorded calls after switching pane'); assert.equal(historyCalls[historyCalls.length - 1][0], 'pause'); // Clear calls while (historyCalls.length) historyCalls.pop(); // Switch back to history. Renderer is still alive and initialized. // Should call refresh + resume. controller.selectPane('history'); await new Promise((resolve) => setTimeout(resolve, 0)); await Promise.resolve(); assert.deepEqual(historyCalls, [ ['refresh', defaultActorHex, true], ['resume'], ]); // Finally hide and destroy await controller.hide(); await Promise.resolve(); assert.equal( historyCalls.some((entry) => Array.isArray(entry) && entry[0] === 'destroy'), true ); assert.equal(controller.profileHistoryRenderer, null); assert.equal(controller.boundProfileHistoryVisibility, null); }); test('handleDirectMessagesRelayWarning throttles status updates', async (t) => { const statusCalls = []; const controller = createController({ showStatus: (message) => statusCalls.push(message), constants: { ENABLE_NIP17_RELAY_WARNING: true, }, }); await controller.load(); const detail = { warning: 'dm-relays-fallback' }; controller.handleDirectMessagesRelayWarning(detail); assert.equal(statusCalls.length, 1); assert.match(statusCalls[0], /Privacy warning/); // Second call should be suppressed controller.handleDirectMessagesRelayWarning(detail); assert.equal(statusCalls.length, 1); // Reset should allow it again controller.handleActiveDmIdentityChanged('newpubkey'); controller.handleDirectMessagesRelayWarning(detail); assert.equal(statusCalls.length, 2); }); test('handleDirectMessagesRelayWarning suppresses status updates when disabled', async (t) => { const statusCalls = []; const controller = createController({ showStatus: (message) => statusCalls.push(message), constants: { ENABLE_NIP17_RELAY_WARNING: false, }, }); await controller.load(); const detail = { warning: 'dm-relays-fallback' }; controller.handleDirectMessagesRelayWarning(detail); assert.equal(statusCalls.length, 0); }); // Account-switch refresh (nip-07 -> saved nsec, modal open): the modal must // re-render for the NEW identity without a manual refresh, and a failure in one // identity-reset step must not skip the others (notably the DM reset that clears // the previous account's conversation). // // test_integrity_note: // change_type: ["new_tests"] // scenarios: // - id: SCN-switch-modal-refresh // given: "profile modal controller loaded" // when: "handleAuthLogin runs for a new identity / refreshOpenPanesForActiveIdentity is called" // then: "DM reset still runs after a sibling throw; the active pane is re-run when open" // observable_outcomes: // - "runIdentityResetStep swallows a throwing step (does not propagate)" // - "a throwing DM reset does not skip renderSavedProfiles" // - "refreshOpenPanesForActiveIdentity re-runs selectPane(activePane) only when open" // determinism_controls: // - "instance method spies; no network/timers relied upon for assertions" // anti_cheat_rationale: // prevents: ["over-mocking internal logic", "hard-coded return value"] // relaxation: // did_relax_any_assertion: false test('runIdentityResetStep isolates a throwing step (does not propagate)', async () => { const controller = createController(); let ran = false; assert.doesNotThrow(() => { controller.runIdentityResetStep('boom', () => { throw new Error('kaboom'); }); controller.runIdentityResetStep('ok', () => { ran = true; }); }); assert.equal(ran, true, 'a later reset step still runs after an earlier one throws'); }); test('handleAuthLogin: a throwing DM reset does not skip the saved-profiles reset', async (t) => { const controller = createController(); await controller.load(); t.after(() => { try { controller.hide({ silent: true }); } catch {} resetRuntimeFlags(); }); // Keep the async collaborators inert so the test only exercises the reset order. controller.hydrateActiveWalletSettings = async () => null; controller.refreshAdminPaneState = async () => {}; controller.refreshOpenPanesForActiveIdentity = () => {}; let dmResetPubkey = 'unset'; controller.handleActiveDmIdentityChanged = (pubkey) => { dmResetPubkey = pubkey; throw new Error('dm reset blew up'); }; let savedProfilesRendered = 0; controller.renderSavedProfiles = () => { savedProfilesRendered += 1; }; const pubkey = 'b'.repeat(64); const result = await controller.handleAuthLogin({ pubkey, activeProfilePubkey: pubkey, identityChanged: true, }); assert.equal(result, true); assert.equal(dmResetPubkey, pubkey, 'DM reset was attempted for the new identity'); assert.equal( savedProfilesRendered, 1, 'the saved-profiles reset still ran even though the DM reset threw', ); }); test('refreshOpenPanesForActiveIdentity re-runs the active pane only when the modal is open', async (t) => { const controller = createController(); await controller.load(); t.after(() => { try { controller.hide({ silent: true }); } catch {} resetRuntimeFlags(); }); const selected = []; controller.selectPane = (name) => { selected.push(name); }; controller.getActivePane = () => 'messages'; controller.isProfileModalOpen = () => false; controller.refreshOpenPanesForActiveIdentity(); assert.deepEqual(selected, [], 'closed modal: no pane refresh'); controller.isProfileModalOpen = () => true; controller.refreshOpenPanesForActiveIdentity(); assert.deepEqual( selected, ['messages'], 'open modal: re-runs the active pane so it reloads for the new identity', ); }); // Hashtag mutation routes through the signer gate (TODO #57): a reloaded nsec // session that lost its in-memory key must trigger a re-unlock prompt, not a // blanket "connect a signer that supports encryption" error — and publish must // NOT run until the signer is confirmed. // // test_integrity_note: // change_type: ["new_tests"] // scenarios: // - id: SCN-hashtag-signer-gate // given: "a profile modal with a mocked ensureEncryptionCapableSigner + publish" // when: "persistHashtagPreferences runs" // then: "publish is gated on the signer being ensured first" // observable_outcomes: // - "gate returns not-ok -> publish never called, throws missing-signer" // - "gate returns ok -> publish runs once" // determinism_controls: // - "mocked service + gate; JSDOM controller; no network" // anti_cheat_rationale: // prevents: ["over-mocking internal logic", "hard-coded return value"] // relaxation: // did_relax_any_assertion: false test('hashtag persist: a not-ok signer gate blocks publish (no blanket dead-end)', async (t) => { const publishCalls = []; let ensureCalls = 0; const controller = createController({ services: { ensureEncryptionCapableSigner: async () => { ensureCalls += 1; return { ok: false, reason: 'cancelled' }; }, hashtagPreferences: { publish: async (payload) => { publishCalls.push(payload); return { ok: true }; }, }, }, }); await controller.load(); t.after(() => { try { controller.hide({ silent: true }); } catch {} resetRuntimeFlags(); }); controller.setActivePubkey('a'.repeat(64)); await assert.rejects( () => controller.hashtagController.persistHashtagPreferences({ pubkey: 'a'.repeat(64), }), (err) => err?.code === 'hashtag-preferences-missing-signer', ); assert.equal(ensureCalls, 1, 'the signer gate ran before publishing'); assert.equal(publishCalls.length, 0, 'publish was NOT attempted after the gate failed'); }); test('hashtag persist: an ok signer gate lets publish proceed', async (t) => { const publishCalls = []; const controller = createController({ services: { ensureEncryptionCapableSigner: async () => ({ ok: true, unlocked: true }), hashtagPreferences: { publish: async (payload) => { publishCalls.push(payload); return { ok: true }; }, }, }, }); await controller.load(); t.after(() => { try { controller.hide({ silent: true }); } catch {} resetRuntimeFlags(); }); controller.setActivePubkey('a'.repeat(64)); await controller.hashtagController.persistHashtagPreferences({ pubkey: 'a'.repeat(64), }); assert.equal(publishCalls.length, 1, 'publish ran once after the gate passed'); }); // "Lock this device" control (TODO #51): only shown when there is a cached // "keep unlocked" key to forget, and clicking it locks the active account. // // test_integrity_note: // change_type: ["new_tests"] // scenarios: // - id: SCN-lock-now-control // given: "a profile modal with mocked isSessionKeptUnlocked + lockKeptUnlockedSession" // when: "updateLockNowVisibility runs / the lock button is clicked" // then: "the button shows only when unlocked; clicking locks the active pubkey" // observable_outcomes: // - "kept-unlocked -> button not hidden; not-unlocked -> hidden" // - "click -> lockKeptUnlockedSession(activePubkey) called once" // determinism_controls: // - "mocked services; JSDOM; no network" // anti_cheat_rationale: // prevents: ["hard-coded return value", "over-mocking internal logic"] // relaxation: // did_relax_any_assertion: false test('Lock this device: shown only when the session is kept unlocked', async (t) => { let unlocked = true; const controller = createController({ services: { isSessionKeptUnlocked: () => unlocked, lockKeptUnlockedSession: () => ({ ok: true }), }, }); await controller.load(); t.after(() => { try { controller.hide({ silent: true }); } catch {} resetRuntimeFlags(); }); controller.setActivePubkey('a'.repeat(64)); controller.updateLockNowVisibility(); assert.ok( controller.lockNowButton instanceof dom.window.HTMLElement, 'the lock button exists in the modal', ); assert.equal( controller.lockNowButton.classList.contains('hidden'), false, 'shown when the session is kept unlocked', ); unlocked = false; controller.updateLockNowVisibility(); assert.equal( controller.lockNowButton.classList.contains('hidden'), true, 'hidden when nothing is cached to lock', ); }); test('Lock this device: clicking locks the active account', async (t) => { const lockCalls = []; const controller = createController({ services: { isSessionKeptUnlocked: () => true, lockKeptUnlockedSession: (pubkey) => { lockCalls.push(pubkey); return { ok: true }; }, }, }); await controller.load(); t.after(() => { try { controller.hide({ silent: true }); } catch {} resetRuntimeFlags(); }); controller.setActivePubkey('a'.repeat(64)); controller.updateLockNowVisibility(); controller.lockNowButton.click(); assert.equal(lockCalls.length, 1, 'lock ran once'); assert.equal(lockCalls[0], controller.getActivePubkey(), 'locked the active account'); });