Files
bitvid/tests/login-modal-controller.test.mjs
T
google-labs-jules[bot]andPR0M3TH3AN cc9064617e fix(tests): remediate known issues in LoginModal and VideoCard tests
- Fix `LoginModalController` async activity leak by binding `setTimeout` to `this.window`.
- Fix `VideoCard` moderation test by explicitly updating `ModerationDecorator` settings.
- Verify `popover.spec.ts` issue persists and update `KNOWN_ISSUES.md`.
- Update `KNOWN_ISSUES.md` to reflect fixes and current status.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-02-07 18:36:41 +00:00

275 lines
9.4 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 LoginModalController from '../js/ui/loginModalController.js';
const loginModalHtml = await readFile(
new URL('../components/login-modal.html', import.meta.url),
'utf8',
);
let dom;
let container;
let controller;
async function waitForAnimationFrame(window, cycles = 1) {
for (let i = 0; i < cycles; i += 1) {
await new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
}
}
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.HTMLButtonElement = dom.window.HTMLButtonElement;
global.HTMLInputElement = dom.window.HTMLInputElement;
global.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
global.HTMLFormElement = dom.window.HTMLFormElement;
global.HTMLTemplateElement = dom.window.HTMLTemplateElement;
global.Node = dom.window.Node;
global.Element = dom.window.Element;
global.MutationObserver = dom.window.MutationObserver;
window.requestAnimationFrame = (callback) => callback(0);
global.requestAnimationFrame = window.requestAnimationFrame;
window.__matchMediaMocks = [];
window.matchMedia = (query) => {
const mql = createMatchMediaList(query, false);
window.__matchMediaMocks.push(mql);
return mql;
};
global.matchMedia = window.matchMedia;
container = document.getElementById('modalMount');
container.innerHTML = loginModalHtml;
});
afterEach(() => {
if (controller) {
controller.destroy();
controller = null;
}
delete global.window;
delete global.document;
delete global.HTMLElement;
delete global.HTMLButtonElement;
delete global.HTMLInputElement;
delete global.HTMLTextAreaElement;
delete global.HTMLFormElement;
delete global.HTMLTemplateElement;
delete global.Node;
delete global.Element;
delete global.MutationObserver;
delete global.requestAnimationFrame;
delete global.matchMedia;
if (dom) {
dom.window.close();
dom = null;
}
});
function createController(options = {}) {
const modalElement = container.querySelector('#loginModal');
return new LoginModalController({
modalElement,
providers: options.providers || [],
services: options.services || {},
callbacks: options.callbacks || {},
});
}
test('LoginModalController shows custom error for empty nsec input', async (t) => {
const providers = [{ id: 'nsec', label: 'Secret Key', login: async () => {} }];
const authService = { requestLogin: async () => {} };
controller = createController({ providers, services: { authService } });
// Simulate clicking the nsec provider button
// This requires us to inspect internal state or simulate events
// But since `promptForNsecOptions` is async and returns a promise, we can call it if we can access the instance.
// However, `handleProviderSelection` is what we usually trigger.
// Let's use internal methods to render providers first
controller.initialize();
const providerButton = container.querySelector('[data-provider-id="nsec"]');
assert.ok(providerButton, 'nsec provider button should be present');
// Trigger click on provider
const clickEvent = new dom.window.Event('click', { bubbles: true });
providerButton.dispatchEvent(clickEvent);
// Wait for the nsec form to appear
await waitForAnimationFrame(window, 5);
const form = container.querySelector('[data-nsec-form]');
assert.ok(form, 'nsec form should be present');
const secretInput = form.querySelector('[data-nsec-secret]');
assert.ok(secretInput, 'secret input should be present');
assert.ok(!secretInput.hasAttribute('required'), 'secret input should NOT have required attribute');
assert.equal(secretInput.required, false, 'secret input property required should be false');
const submitButton = form.querySelector('[data-nsec-submit]');
assert.ok(submitButton, 'submit button should be present');
const errorNode = form.querySelector('[data-nsec-error]');
assert.ok(errorNode, 'error node should be present');
assert.ok(errorNode.classList.contains('hidden'), 'error node should be initially hidden');
// Submit with empty form
const submitEvent = new dom.window.Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(submitEvent);
// Wait for validation
await waitForAnimationFrame(window, 2);
assert.ok(!errorNode.classList.contains('hidden'), 'error node should be visible after empty submit');
assert.equal(errorNode.textContent, 'Paste an nsec, hex key, or mnemonic to continue.');
});
test('LoginModalController does not set required attribute when toggling modes', async (t) => {
const providers = [{ id: 'nsec', label: 'Secret Key', login: async () => {} }];
const storedMetadata = { hasEncryptedKey: true };
const nostrClient = {
getStoredSessionActorMetadata: () => storedMetadata
};
const authService = { requestLogin: async () => {} };
controller = createController({
providers,
services: { nostrClient, authService }
});
controller.initialize();
const providerButton = container.querySelector('[data-provider-id="nsec"]');
providerButton.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
await waitForAnimationFrame(window, 5);
const form = container.querySelector('[data-nsec-form]');
const unlockRadio = form.querySelector('[data-nsec-mode="unlock"]');
const importRadio = form.querySelector('[data-nsec-mode="import"]');
const secretInput = form.querySelector('[data-nsec-secret]');
const unlockPassphrase = form.querySelector('[data-nsec-unlock-passphrase]');
// Initially should be in unlock mode because hasEncryptedKey is true
assert.ok(unlockRadio.checked);
assert.equal(secretInput.disabled, true);
assert.equal(secretInput.required, false);
// Unlock passphrase should be enabled but NOT required (because we want custom validation)
assert.equal(unlockPassphrase.disabled, false);
assert.equal(unlockPassphrase.required, false);
// Switch to import mode
importRadio.click();
unlockRadio.checked = false;
importRadio.checked = true;
importRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
await waitForAnimationFrame(window, 2);
// Secret input should be enabled and NOT required
assert.equal(secretInput.disabled, false);
assert.equal(secretInput.required, false);
// Unlock passphrase should be disabled
assert.equal(unlockPassphrase.disabled, true);
assert.equal(unlockPassphrase.required, false);
});
test('LoginModalController shows NIP-46 handshake panel', async (t) => {
const providers = [{ id: 'nip46', label: 'Remote Signer', login: async () => {} }];
const authService = { requestLogin: async () => {} };
controller = createController({ providers, services: { authService } });
controller.initialize();
const providerButton = container.querySelector('[data-provider-id="nip46"]');
assert.ok(providerButton, 'nip46 provider button should be present');
// Trigger click on provider
const clickEvent = new dom.window.Event('click', { bubbles: true });
providerButton.dispatchEvent(clickEvent);
// Wait for the nip46 form to appear
await waitForAnimationFrame(window, 5);
const form = container.querySelector('[data-nip46-form]');
assert.ok(form, 'nip46 form should be present');
const handshakePanel = form.querySelector('[data-nip46-handshake-panel]');
assert.ok(handshakePanel, 'handshake panel should be present');
assert.ok(!handshakePanel.classList.contains('hidden'), 'handshake panel should not be hidden');
});