Files
bitvid/tests/profile-modal-controller.test.mjs
T
thePR0M3TH3ANandClaude Fable 5 d1a578f3a2 profile modal: always-visible "Acting as" identity card (#43)
Identity pill top-center on every pane, straddling the header divider, so
users always see which profile they're acting as (e.g. before uploading).
Populated in renderSavedProfiles (same resolveMeta path as the Account pane
card, so it refreshes on profile switch), hidden when logged out, and
click-through jumps to the Account pane.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 21:24:13 -04:00

1701 lines
53 KiB
JavaScript

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;
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(
'<!DOCTYPE html><html><body><div id="modalMount"></div></body></html>',
{
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;
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;
});
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');
assert.ok(layout && menuWrapper && paneWrapper && backButton);
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('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');
});