diff --git a/js/ui/components/EmbedVideoModal.js b/js/ui/components/EmbedVideoModal.js index 54e5ab8e..77a12aad 100644 --- a/js/ui/components/EmbedVideoModal.js +++ b/js/ui/components/EmbedVideoModal.js @@ -414,19 +414,9 @@ export class EmbedVideoModal { textarea.select(); textarea.setSelectionRange(0, textarea.value.length); - let succeeded = false; - try { - succeeded = this.document.execCommand("copy"); - } catch (error) { - logger.user.warn("Embed modal copy fallback failed.", error); - succeeded = false; - } - - if (succeeded) { - this.callbacks.showSuccess("Embed code copied to clipboard!"); - } else { - this.callbacks.showError("Unable to copy embed code. Please copy it manually."); - } + this.callbacks.showError( + "Unable to copy embed code. Please copy it manually." + ); } async open({ video, triggerElement } = {}) { diff --git a/js/ui/loginModalController.js b/js/ui/loginModalController.js index b35e473c..7659bfcf 100644 --- a/js/ui/loginModalController.js +++ b/js/ui/loginModalController.js @@ -1550,11 +1550,8 @@ export default class LoginModalController { if (!copied && handshakeInput.select) { try { handshakeInput.select(); - if (this.document?.execCommand) { - copied = this.document.execCommand("copy"); - } } catch (error) { - copied = false; + // ignore } } if (copied) { diff --git a/tests/login-modal-controller.test.mjs b/tests/login-modal-controller.test.mjs index 27e2aa13..b465deed 100644 --- a/tests/login-modal-controller.test.mjs +++ b/tests/login-modal-controller.test.mjs @@ -110,6 +110,18 @@ beforeEach(() => { }; 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; }); @@ -133,6 +145,7 @@ afterEach(() => { delete global.MutationObserver; delete global.requestAnimationFrame; delete global.matchMedia; + delete global.navigator; if (dom) { dom.window.close(); @@ -272,3 +285,124 @@ test('LoginModalController shows NIP-46 handshake panel', async (t) => { 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'); +}); diff --git a/tests/unit/ui/EmbedVideoModal.test.mjs b/tests/unit/ui/EmbedVideoModal.test.mjs new file mode 100644 index 00000000..150058c7 --- /dev/null +++ b/tests/unit/ui/EmbedVideoModal.test.mjs @@ -0,0 +1,146 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const originalFilePath = path.resolve(__dirname, "../../../js/ui/components/EmbedVideoModal.js"); +const tempFilePath = path.resolve(__dirname, "../../../js/ui/components/EmbedVideoModal.test_temp.js"); + +// Setup JSDOM +const dom = new JSDOM(` +
+
+
+ +
+
`); + +globalThis.window = dom.window; +globalThis.document = dom.window.document; +globalThis.HTMLElement = dom.window.HTMLElement; + +// Mock navigator on globalThis +Object.defineProperty(globalThis, 'navigator', { + value: { + clipboard: { + writeText: async () => {}, + }, + userAgent: 'node-test', + }, + writable: true, + configurable: true, +}); + +// Prepare mocks on global +globalThis.mockStaticModal = { + prepareStaticModal: () => {}, + openStaticModal: () => {}, + closeStaticModal: () => {}, +}; + +globalThis.mockLogger = { + user: { + warn: () => {}, + }, +}; + +globalThis.mockVideoPointer = { + resolveVideoPointer: () => ({ pointer: ["nevent", "123", ""] }), + buildVideoAddressPointer: () => "naddr:123", +}; + +// Create temp file with replaced imports +const content = fs.readFileSync(originalFilePath, "utf8"); +let newContent = content + .replace( + /import\s*\{\s*prepareStaticModal,\s*openStaticModal,\s*closeStaticModal,?\s*\}\s*from\s*"\.\/staticModalAccessibility\.js";/s, + 'const { prepareStaticModal, openStaticModal, closeStaticModal } = globalThis.mockStaticModal;' + ) + .replace( + /import\s*logger\s*from\s*"\.\.\/\.\.\/utils\/logger\.js";/s, + 'const logger = globalThis.mockLogger;' + ) + .replace( + /import\s*\{\s*resolveVideoPointer,\s*buildVideoAddressPointer,?\s*\}\s*from\s*"\.\.\/\.\.\/utils\/videoPointer\.js";/s, + 'const { resolveVideoPointer, buildVideoAddressPointer } = globalThis.mockVideoPointer;' + ); + +fs.writeFileSync(tempFilePath, newContent); + +// Import the temp module +const { EmbedVideoModal } = await import(pathToFileURL(tempFilePath).href); + +test("EmbedVideoModal handleCopy uses navigator.clipboard", async (t) => { + const modal = new EmbedVideoModal(); + await modal.load(); + + modal.snippetTextarea.value = "test snippet"; + + let clipboardText = ""; + let writeTextCalled = false; + + // Set mock + globalThis.navigator.clipboard.writeText = async (text) => { + writeTextCalled = true; + clipboardText = text; + }; + + let successMsg = ""; + modal.callbacks.showSuccess = (msg) => { successMsg = msg; }; + + await modal.handleCopy(); + + assert.equal(writeTextCalled, true, "Should call navigator.clipboard.writeText"); + assert.equal(clipboardText, "test snippet", "Should write correct text to clipboard"); + assert.ok(successMsg.includes("copied"), "Should show success message"); +}); + +test("EmbedVideoModal handleCopy falls back to selection when clipboard fails", async (t) => { + const modal = new EmbedVideoModal(); + await modal.load(); + + modal.snippetTextarea.value = "test snippet"; + + globalThis.navigator.clipboard.writeText = async () => { + throw new Error("Clipboard error"); + }; + + let errorMsg = ""; + modal.callbacks.showError = (msg) => { errorMsg = msg; }; + + let selectCalled = false; + modal.snippetTextarea.select = () => { selectCalled = true; }; + + const originalExecCommand = document.execCommand; + let execCommandCalled = false; + document.execCommand = () => { execCommandCalled = true; return false; }; + + await modal.handleCopy(); + + // Expect NO execCommand call + assert.equal(execCommandCalled, false, "Should NOT call execCommand"); + assert.ok(errorMsg.includes("manually"), "Should show manual copy message"); + + document.execCommand = originalExecCommand; +}); + +// Cleanup +test.after(() => { + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } +});