Files
bitvid/tests/login-modal-controller.test.mjs
T

417 lines
15 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;
// Mock navigator
Object.defineProperty(global, 'navigator', {
value: {
clipboard: {
writeText: async () => {}
},
userAgent: 'node'
},
writable: true,
configurable: true
});
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;
delete global.navigator;
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('login modal keeps Nostr sign-in options visible beside BitLogin', () => {
const nostrSection = container.querySelector('[aria-labelledby="nostrLoginOptionsTitle"]');
assert.ok(nostrSection, 'Nostr login options should be visible without expanding a disclosure');
assert.ok(nostrSection.querySelector('[data-login-providers]'));
assert.equal(container.querySelector('details'), null);
assert.match(container.querySelector('#bitloginMount').textContent, /BitLogin/);
});
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');
});
test('LoginModalController NIP-46 copy button uses navigator.clipboard', async (t) => {
const providers = [{ id: 'nip46', label: 'Remote Signer', login: async () => {} }];
// Mock auth service to provide URI
const authService = {
requestLogin: async (opts) => {
if (opts.onHandshakePrepared) {
// simulate async URI generation
setTimeout(() => {
opts.onHandshakePrepared({ uri: 'nostr:connect:test' });
}, 10);
}
return new Promise(() => {}); // hang forever (simulating pending login)
}
};
controller = createController({ providers, services: { authService } });
controller.initialize();
const providerButton = container.querySelector('[data-provider-id="nip46"]');
providerButton.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
await waitForAnimationFrame(window, 5);
// Wait for autoStart and requestLogin response
await new Promise(resolve => setTimeout(resolve, 50));
await waitForAnimationFrame(window, 5);
const form = container.querySelector('[data-nip46-form]');
assert.ok(form, 'Form should be present');
const copyButton = form.querySelector('[data-nip46-copy-uri]');
const handshakeInput = form.querySelector('[data-nip46-handshake-uri]');
assert.equal(handshakeInput.value, 'nostr:connect:test', 'Input should have URI');
assert.equal(copyButton.disabled, false, 'Copy button should be enabled');
// Setup clipboard mock verification
let writeTextCalled = false;
let clipboardText = '';
global.navigator.clipboard.writeText = async (text) => {
writeTextCalled = true;
clipboardText = text;
};
// Spy on execCommand
let execCommandCalled = false;
document.execCommand = () => { execCommandCalled = true; return false; };
copyButton.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
// Wait for async writeText
await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(writeTextCalled, true, 'Should use navigator.clipboard');
assert.equal(clipboardText, 'nostr:connect:test', 'Should copy correct text');
assert.equal(execCommandCalled, false, 'Should NOT use execCommand');
assert.equal(copyButton.textContent, 'Copied!', 'Button should show success feedback');
});
test('LoginModalController NIP-46 copy button selects text on failure', async (t) => {
const providers = [{ id: 'nip46', label: 'Remote Signer', login: async () => {} }];
const authService = {
requestLogin: async (opts) => {
if (opts.onHandshakePrepared) {
setTimeout(() => {
opts.onHandshakePrepared({ uri: 'nostr:connect:test' });
}, 10);
}
return new Promise(() => {});
}
};
controller = createController({ providers, services: { authService } });
controller.initialize();
const providerButton = container.querySelector('[data-provider-id="nip46"]');
providerButton.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
await waitForAnimationFrame(window, 5);
await new Promise(resolve => setTimeout(resolve, 50));
await waitForAnimationFrame(window, 5);
const form = container.querySelector('[data-nip46-form]');
const copyButton = form.querySelector('[data-nip46-copy-uri]');
const handshakeInput = form.querySelector('[data-nip46-handshake-uri]');
// Mock clipboard failure
global.navigator.clipboard.writeText = async () => {
throw new Error('Clipboard error');
};
let execCommandCalled = false;
document.execCommand = () => { execCommandCalled = true; return false; };
let selectCalled = false;
// JSDOM input element supports select?
if (!handshakeInput.select) {
handshakeInput.select = () => { selectCalled = true; };
} else {
// Wrap it
const originalSelect = handshakeInput.select.bind(handshakeInput);
handshakeInput.select = () => {
selectCalled = true;
originalSelect();
};
}
copyButton.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(selectCalled, true, 'Should select input text on fallback');
assert.equal(execCommandCalled, false, 'Should NOT use execCommand on fallback');
// Check status message
const statusNode = form.querySelector('[data-nip46-status]');
assert.ok(!statusNode.classList.contains('hidden'), 'Status should be visible');
assert.ok(statusNode.textContent.includes('fallback'), 'Status should mention fallback');
});