From 24290d4c19e2121818f1b998363e923cda9fc9cc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 04:52:40 +0000 Subject: [PATCH] Refactor clipboard copy to use navigator.clipboard API - Replaced deprecated `document.execCommand("copy")` with `navigator.clipboard.writeText` in `EmbedVideoModal.js`. - Simplified fallback mechanism in `EmbedVideoModal.js` to select text and prompt user for manual copy. - Removed `document.execCommand("copy")` fallback in `LoginModalController.js` and updated tests. - Added unit tests for `EmbedVideoModal` copy functionality. - Updated `LoginModalController` tests to cover NIP-46 copy button behavior. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/ui/components/EmbedVideoModal.js | 16 +-- js/ui/loginModalController.js | 5 +- tests/login-modal-controller.test.mjs | 134 +++++++++++++++++++++++ tests/unit/ui/EmbedVideoModal.test.mjs | 146 +++++++++++++++++++++++++ 4 files changed, 284 insertions(+), 17 deletions(-) create mode 100644 tests/unit/ui/EmbedVideoModal.test.mjs 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(` +