mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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 <[email protected]>
This commit is contained in:
1 parent
84e7f50f96
commit
24290d4c19
4 files changed
+284
-17
No files matched your search
@@ -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 } = {}) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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(`<!DOCTYPE html>
|
||||
<div id="modalContainer">
|
||||
<div id="embedVideoModal">
|
||||
<div class="bv-modal-backdrop"></div>
|
||||
<div class="modal-sheet">
|
||||
<button id="closeEmbedVideoModal"></button>
|
||||
<button id="cancelEmbedVideo"></button>
|
||||
<button id="copyEmbedVideo"></button>
|
||||
<input type="radio" id="embedVideoSourceCdn" />
|
||||
<input type="radio" id="embedVideoSourceP2p" />
|
||||
<input id="embedVideoWidth" />
|
||||
<input id="embedVideoHeight" />
|
||||
<textarea id="embedVideoSnippet"></textarea>
|
||||
<div id="embedVideoStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user