Merge branch 'main' into pr-734
This commit is contained in:
207
src-tauri/src/commands/clipboard.rs
Normal file
207
src-tauri/src/commands/clipboard.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
#[cfg(desktop)]
|
||||
use std::io::Write;
|
||||
#[cfg(desktop)]
|
||||
use std::process::{Child, Command, Output, Stdio};
|
||||
#[cfg(desktop)]
|
||||
use std::thread;
|
||||
#[cfg(desktop)]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(desktop)]
|
||||
const NATIVE_CLIPBOARD_COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
#[cfg(desktop)]
|
||||
const NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("pbcopy")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
crate::hidden_command("pbpaste")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("clip.exe")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("powershell.exe");
|
||||
command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn clipboard_failure_message(stderr: &[u8]) -> String {
|
||||
let message = String::from_utf8_lossy(stderr).trim().to_string();
|
||||
if message.is_empty() {
|
||||
"Native clipboard command failed".to_string()
|
||||
} else {
|
||||
format!("Native clipboard command failed: {message}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn clipboard_timeout_message(timeout: Duration) -> String {
|
||||
format!(
|
||||
"Native clipboard command timed out after {}ms",
|
||||
timeout.as_millis()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn wait_for_native_clipboard_output(mut child: Child, timeout: Duration) -> Result<Output, String> {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_status)) => {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Native clipboard command did not finish: {e}"));
|
||||
}
|
||||
Ok(None) if started.elapsed() >= timeout => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(clipboard_timeout_message(timeout));
|
||||
}
|
||||
Ok(None) => thread::sleep(NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL),
|
||||
Err(e) => return Err(format!("Native clipboard command did not finish: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn write_native_clipboard_with_timeout(
|
||||
mut command: Command,
|
||||
text: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<(), String> {
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open native clipboard command: {e}"))?;
|
||||
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?;
|
||||
stdin
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|e| format!("Failed to write native clipboard text: {e}"))?;
|
||||
drop(stdin);
|
||||
|
||||
let output = wait_for_native_clipboard_output(child, timeout)?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn write_native_clipboard(command: Command, text: &str) -> Result<(), String> {
|
||||
write_native_clipboard_with_timeout(command, text, NATIVE_CLIPBOARD_COMMAND_TIMEOUT)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn read_native_clipboard_with_timeout(
|
||||
mut command: Command,
|
||||
timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
let child = command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to read native clipboard text: {e}"))?;
|
||||
|
||||
let output = wait_for_native_clipboard_output(child, timeout)?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn read_native_clipboard(command: Command) -> Result<String, String> {
|
||||
read_native_clipboard_with_timeout(command, NATIVE_CLIPBOARD_COMMAND_TIMEOUT)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn copy_text_to_clipboard(text: String) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || write_native_clipboard(clipboard_command(), &text))
|
||||
.await
|
||||
.map_err(|e| format!("Native clipboard task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn read_text_from_clipboard() -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || read_native_clipboard(clipboard_read_command()))
|
||||
.await
|
||||
.map_err(|e| format!("Native clipboard task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn read_text_from_clipboard() -> Result<String, String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
#[test]
|
||||
fn native_clipboard_write_times_out_slow_commands() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "cat >/dev/null; sleep 2"]);
|
||||
|
||||
let started = Instant::now();
|
||||
let result =
|
||||
write_native_clipboard_with_timeout(command, "copy me", Duration::from_millis(50));
|
||||
|
||||
let error = result.expect_err("slow clipboard command should time out");
|
||||
assert!(
|
||||
error.contains("timed out"),
|
||||
"unexpected clipboard timeout error: {error}"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(1),
|
||||
"clipboard timeout should return promptly"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod ai;
|
||||
mod clipboard;
|
||||
mod delete;
|
||||
mod folders;
|
||||
mod git;
|
||||
@@ -13,6 +14,7 @@ mod version;
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub use ai::*;
|
||||
pub use clipboard::*;
|
||||
pub use delete::*;
|
||||
pub use folders::*;
|
||||
pub use git::*;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
#[cfg(desktop)]
|
||||
use std::io::Write;
|
||||
#[cfg(desktop)]
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use crate::menu;
|
||||
@@ -142,113 +140,6 @@ pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String
|
||||
.map_err(|e| format!("MCP config task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("pbcopy")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
crate::hidden_command("pbpaste")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("clip.exe")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("powershell.exe");
|
||||
command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn clipboard_failure_message(stderr: &[u8]) -> String {
|
||||
let message = String::from_utf8_lossy(stderr).trim().to_string();
|
||||
if message.is_empty() {
|
||||
"Native clipboard command failed".to_string()
|
||||
} else {
|
||||
format!("Native clipboard command failed: {message}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn write_native_clipboard(mut command: Command, text: &str) -> Result<(), String> {
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open native clipboard command: {e}"))?;
|
||||
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?;
|
||||
stdin
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|e| format!("Failed to write native clipboard text: {e}"))?;
|
||||
drop(stdin);
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Native clipboard command did not finish: {e}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn read_native_clipboard(mut command: Command) -> Result<String, String> {
|
||||
let output = command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to read native clipboard text: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn copy_text_to_clipboard(text: String) -> Result<(), String> {
|
||||
write_native_clipboard(clipboard_command(), &text)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn read_text_from_clipboard() -> Result<String, String> {
|
||||
read_native_clipboard(clipboard_read_command())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(
|
||||
@@ -299,18 +190,6 @@ pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, Strin
|
||||
Err("MCP is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn read_text_from_clipboard() -> Result<String, String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ArrowSquareOut as ExternalLink, Copy } from '@phosphor-icons/react'
|
||||
import { Component, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
GridSuggestionMenuController,
|
||||
BlockNoteViewRaw,
|
||||
@@ -26,7 +25,7 @@ import { useEditorTheme } from '../hooks/useTheme'
|
||||
import { useImageDrop } from '../hooks/useImageDrop'
|
||||
import { useImageLightbox } from '../hooks/useImageLightbox'
|
||||
import { createTranslator, type AppLocale } from '../lib/i18n'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { writeClipboardText } from '../utils/clipboardText'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { searchEmojis, type EmojiEntry } from '../utils/emoji'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
@@ -476,19 +475,6 @@ function codeBlockText(codeBlock: HTMLElement): string {
|
||||
return codeElement?.textContent ?? ''
|
||||
}
|
||||
|
||||
async function writeClipboardText(text: string): Promise<void> {
|
||||
if (isTauri()) {
|
||||
await invoke('copy_text_to_clipboard', { text })
|
||||
return
|
||||
}
|
||||
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error('Clipboard API is unavailable')
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
type CodeBlockCopyTarget = {
|
||||
codeBlock: HTMLElement
|
||||
left: number
|
||||
|
||||
66
src/utils/clipboardText.test.ts
Normal file
66
src/utils/clipboardText.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { writeClipboardText } from './clipboardText'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
|
||||
const originalClipboard = navigator.clipboard
|
||||
const mockInvoke = vi.mocked(invoke)
|
||||
|
||||
function setClipboard(writeText: ReturnType<typeof vi.fn> | undefined) {
|
||||
Object.defineProperty(window.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: writeText ? { writeText } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
describe('writeClipboardText', () => {
|
||||
beforeEach(() => {
|
||||
mockInvoke.mockReset()
|
||||
;(globalThis as { isTauri?: boolean }).isTauri = false
|
||||
Object.defineProperty(window.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the Web Clipboard API when it is available', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
setClipboard(writeText)
|
||||
|
||||
await writeClipboardText('copy me')
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('copy me')
|
||||
expect(mockInvoke).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers the Web Clipboard API inside Tauri', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
setClipboard(writeText)
|
||||
;(globalThis as { isTauri?: boolean }).isTauri = true
|
||||
|
||||
await writeClipboardText('copy me')
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('copy me')
|
||||
expect(mockInvoke).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the native command when Web Clipboard copy fails inside Tauri', async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error('permission denied'))
|
||||
setClipboard(writeText)
|
||||
;(globalThis as { isTauri?: boolean }).isTauri = true
|
||||
mockInvoke.mockResolvedValue(undefined)
|
||||
|
||||
await writeClipboardText('copy me')
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('copy_text_to_clipboard', { text: 'copy me' })
|
||||
})
|
||||
|
||||
it('reports unavailable clipboard support outside Tauri', async () => {
|
||||
setClipboard(undefined)
|
||||
|
||||
await expect(writeClipboardText('copy me')).rejects.toThrow('Clipboard API is unavailable')
|
||||
})
|
||||
})
|
||||
38
src/utils/clipboardText.ts
Normal file
38
src/utils/clipboardText.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
const NATIVE_COPY_TEXT_COMMAND = 'copy_text_to_clipboard'
|
||||
|
||||
type WebClipboardResult =
|
||||
| { status: 'copied' }
|
||||
| { status: 'failed'; error: unknown }
|
||||
| { status: 'unavailable' }
|
||||
|
||||
function hasWebClipboard(): boolean {
|
||||
return typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function'
|
||||
}
|
||||
|
||||
async function writeWebClipboardText(text: string): Promise<WebClipboardResult> {
|
||||
if (!hasWebClipboard()) return { status: 'unavailable' }
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return { status: 'copied' }
|
||||
} catch (error) {
|
||||
return { status: 'failed', error }
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeClipboardText(text: string): Promise<void> {
|
||||
const webResult = await writeWebClipboardText(text)
|
||||
if (webResult.status === 'copied') return
|
||||
|
||||
if (isTauri()) {
|
||||
await invoke(NATIVE_COPY_TEXT_COMMAND, { text })
|
||||
return
|
||||
}
|
||||
|
||||
if (webResult.status === 'failed') throw webResult.error
|
||||
|
||||
throw new Error('Clipboard API is unavailable')
|
||||
}
|
||||
@@ -59,12 +59,15 @@ async function copySelectedText(page: Page) {
|
||||
return readClipboard(page)
|
||||
}
|
||||
|
||||
async function copyRichCodeBlockFromButton(page: Page, blockIndex: number) {
|
||||
async function expectRichCodeBlockButtonCopy(page: Page, blockIndex: number, expectedText: string) {
|
||||
await clearClipboard(page)
|
||||
const codeBlock = page.locator('.bn-block-content[data-content-type="codeBlock"]').nth(blockIndex)
|
||||
await expect(codeBlock).toBeVisible()
|
||||
await codeBlock.hover()
|
||||
await page.getByRole('button', { name: 'Copy code to clipboard' }).click()
|
||||
return readClipboard(page)
|
||||
const copyButton = page.getByRole('button', { name: 'Copy code to clipboard' })
|
||||
await expect(copyButton).toBeVisible()
|
||||
await copyButton.click()
|
||||
await expect.poll(() => readClipboard(page)).toBe(expectedText)
|
||||
}
|
||||
|
||||
async function selectRichCodeBlock(page: Page, blockIndex: number) {
|
||||
@@ -128,9 +131,9 @@ test.describe('Fenced code copy', () => {
|
||||
await noteItem.click()
|
||||
await expect(page.locator(RICH_CODE_SELECTOR)).toHaveCount(3, { timeout: 10_000 })
|
||||
|
||||
await expect.poll(() => copyRichCodeBlockFromButton(page, 0)).toBe(JSON_SNIPPET)
|
||||
await expect.poll(() => copyRichCodeBlockFromButton(page, 1)).toBe(TYPESCRIPT_SNIPPET)
|
||||
await expect.poll(() => copyRichCodeBlockFromButton(page, 2)).toBe(CJK_SNIPPET)
|
||||
await expectRichCodeBlockButtonCopy(page, 0, JSON_SNIPPET)
|
||||
await expectRichCodeBlockButtonCopy(page, 1, TYPESCRIPT_SNIPPET)
|
||||
await expectRichCodeBlockButtonCopy(page, 2, CJK_SNIPPET)
|
||||
|
||||
await selectRichCodeBlock(page, 0)
|
||||
await expect.poll(() => copySelectedText(page)).toBe(JSON_SNIPPET)
|
||||
|
||||
Reference in New Issue
Block a user