mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-10-23 00:21:18 +00:00
feat(components): improve AboutPage, CallPage, DebugLogsPage, and SettingsPage with improved layout, error handling, and clipboard functionality; add tests for environment path fallbacks and log copying
This commit is contained in:
7 files changed
+183
-51
No files matched your search
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
|
||||
<div
|
||||
class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6 text-gray-900 dark:text-zinc-100"
|
||||
class="flex-1 overflow-y-auto overflow-x-hidden w-full min-w-0 px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6 text-gray-900 dark:text-zinc-100"
|
||||
>
|
||||
<div class="space-y-0 w-full max-w-4xl mx-auto pb-16 sm:pb-24">
|
||||
<div class="space-y-0 w-full max-w-4xl mx-auto pb-16 sm:pb-24 min-w-0">
|
||||
<div v-if="appInfo" class="about-section">
|
||||
<div class="flex flex-col gap-8 lg:flex-row lg:items-center">
|
||||
<!-- Logo & Title -->
|
||||
@@ -271,13 +271,13 @@
|
||||
<v-icon icon="mdi-server" size="14"></v-icon>
|
||||
Environment Information
|
||||
</div>
|
||||
<div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 text-sm">
|
||||
<div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 text-sm min-w-0">
|
||||
<div>
|
||||
<div class="glass-label !text-[10px] mb-2 opacity-50">Reticulum Config</div>
|
||||
<div
|
||||
class="monospace-field !bg-zinc-50 dark:!bg-zinc-950 break-all text-[11px] !p-3 rounded-xl border border-zinc-100 dark:border-zinc-800"
|
||||
>
|
||||
{{ appInfo.reticulum_config_path }}
|
||||
{{ appInfo.reticulum_config_path || "unknown" }}
|
||||
</div>
|
||||
<button
|
||||
v-if="isElectron"
|
||||
@@ -293,7 +293,7 @@
|
||||
<div
|
||||
class="monospace-field !bg-zinc-50 dark:!bg-zinc-950 break-all text-[11px] !p-3 rounded-xl border border-zinc-100 dark:border-zinc-800"
|
||||
>
|
||||
{{ appInfo.database_path }}
|
||||
{{ appInfo.database_path || "unknown" }}
|
||||
</div>
|
||||
<button
|
||||
v-if="isElectron"
|
||||
@@ -390,8 +390,8 @@
|
||||
<v-icon icon="mdi-link-variant" size="14"></v-icon>
|
||||
Dependency Chain
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 relative">
|
||||
<div class="flex flex-col space-y-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 relative min-w-0">
|
||||
<div class="flex flex-col space-y-8 min-w-0">
|
||||
<div class="flex items-center gap-5">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20 shadow-sm"
|
||||
@@ -463,8 +463,8 @@
|
||||
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
|
||||
Reticulum Network Stack
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<div class="text-xs font-mono font-bold text-gray-400">
|
||||
<div class="flex flex-wrap items-center gap-2 mt-1 min-w-0">
|
||||
<div class="text-xs font-mono font-bold text-gray-400 shrink-0">
|
||||
v{{ appInfo.rns_version }}
|
||||
</div>
|
||||
<div
|
||||
@@ -473,7 +473,7 @@
|
||||
? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
|
||||
: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
]"
|
||||
class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded border"
|
||||
class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded border max-w-full break-words"
|
||||
>
|
||||
{{
|
||||
appInfo.is_connected_to_shared_instance
|
||||
@@ -486,9 +486,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-8 min-w-0">
|
||||
<div
|
||||
class="py-4 sm:p-5 border-t border-gray-200/60 dark:border-zinc-800/80 sm:border sm:rounded-2xl sm:bg-black/[0.02] dark:sm:bg-white/[0.02]"
|
||||
class="py-4 sm:p-5 border-t border-gray-200/60 dark:border-zinc-800/80 sm:border sm:rounded-2xl sm:bg-black/[0.02] dark:sm:bg-white/[0.02] min-w-0"
|
||||
>
|
||||
<div
|
||||
class="text-[10px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-4"
|
||||
@@ -962,11 +962,36 @@ export default {
|
||||
}
|
||||
},
|
||||
environmentInfo() {
|
||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent || "" : "";
|
||||
let platform = typeof navigator !== "undefined" && navigator.platform ? navigator.platform : "";
|
||||
if (
|
||||
!platform &&
|
||||
typeof navigator !== "undefined" &&
|
||||
navigator.userAgentData &&
|
||||
navigator.userAgentData.platform
|
||||
) {
|
||||
platform = navigator.userAgentData.platform;
|
||||
}
|
||||
if (!platform && /Android/i.test(ua)) {
|
||||
platform = "Android";
|
||||
}
|
||||
if (!platform && this.appInfo && this.appInfo.host_platform) {
|
||||
platform = this.appInfo.host_platform;
|
||||
}
|
||||
if (!platform) {
|
||||
platform = "unknown";
|
||||
}
|
||||
const language =
|
||||
typeof navigator !== "undefined" && navigator.language
|
||||
? navigator.language
|
||||
: typeof navigator !== "undefined" && navigator.languages && navigator.languages[0]
|
||||
? navigator.languages[0]
|
||||
: "unknown";
|
||||
return {
|
||||
platform: navigator?.platform || "unknown",
|
||||
language: navigator?.language || "unknown",
|
||||
userAgent: navigator?.userAgent || "unknown",
|
||||
backendUrl: window?.location?.origin || "unknown",
|
||||
platform,
|
||||
language,
|
||||
userAgent: ua || "unknown",
|
||||
backendUrl: typeof window !== "undefined" && window.location ? window.location.origin : "unknown",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2411,15 +2411,14 @@ export default {
|
||||
},
|
||||
getMediaDevicesApi() {
|
||||
const mediaDevices = navigator?.mediaDevices;
|
||||
if (
|
||||
!mediaDevices ||
|
||||
typeof mediaDevices.getUserMedia !== "function" ||
|
||||
typeof mediaDevices.enumerateDevices !== "function"
|
||||
) {
|
||||
if (!mediaDevices || typeof mediaDevices.getUserMedia !== "function") {
|
||||
return null;
|
||||
}
|
||||
return mediaDevices;
|
||||
},
|
||||
hasEnumerateDevicesApi(mediaDevices) {
|
||||
return Boolean(mediaDevices && typeof mediaDevices.enumerateDevices === "function");
|
||||
},
|
||||
getAudioContextConstructor() {
|
||||
return window.AudioContext || window.webkitAudioContext || null;
|
||||
},
|
||||
@@ -2501,17 +2500,10 @@ export default {
|
||||
return;
|
||||
}
|
||||
await this.refreshAudioDevices();
|
||||
const hasInputDevices = (this.audioInputDevices || []).length > 0;
|
||||
if (!hasInputDevices) {
|
||||
await this.disableWebAudioBridgeWithError(
|
||||
"call.no_audio_input_found",
|
||||
new Error("No audio input devices detected"),
|
||||
"start-no-input-devices"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const validDeviceIds = new Set((this.audioInputDevices || []).map((d) => d.deviceId));
|
||||
const canEnumerate = this.hasEnumerateDevicesApi(mediaDevices);
|
||||
const validDeviceIds = canEnumerate
|
||||
? new Set((this.audioInputDevices || []).map((d) => d.deviceId))
|
||||
: new Set();
|
||||
const hasSelectedDevice = this.selectedAudioInputId && validDeviceIds.has(this.selectedAudioInputId);
|
||||
const constraints = hasSelectedDevice
|
||||
? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
|
||||
@@ -2583,11 +2575,13 @@ export default {
|
||||
if (!mediaDevices) {
|
||||
throw new Error("navigator.mediaDevices is unavailable");
|
||||
}
|
||||
const devices = await mediaDevices.enumerateDevices();
|
||||
const hasAudioInput = devices.some((d) => d.kind === "audioinput");
|
||||
if (devices.length > 0 && !hasAudioInput) {
|
||||
ToastUtils.error(this.$t("call.no_audio_input_found"));
|
||||
return false;
|
||||
if (this.hasEnumerateDevicesApi(mediaDevices)) {
|
||||
const devices = await mediaDevices.enumerateDevices();
|
||||
const hasAudioInput = devices.some((d) => d.kind === "audioinput");
|
||||
if (devices.length > 0 && !hasAudioInput) {
|
||||
ToastUtils.error(this.$t("call.no_audio_input_found"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const constraints = this.selectedAudioInputId
|
||||
@@ -2617,6 +2611,11 @@ export default {
|
||||
this.audioOutputDevices = [];
|
||||
return;
|
||||
}
|
||||
if (!this.hasEnumerateDevicesApi(mediaDevices)) {
|
||||
this.audioInputDevices = [];
|
||||
this.audioOutputDevices = [];
|
||||
return;
|
||||
}
|
||||
const devices = await mediaDevices.enumerateDevices();
|
||||
this.audioInputDevices = devices.filter((d) => d.kind === "audioinput");
|
||||
this.audioOutputDevices = devices.filter((d) => d.kind === "audiooutput");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
|
||||
<div
|
||||
class="flex flex-col h-full overflow-hidden w-full px-4 md:px-5 lg:px-8 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
|
||||
class="flex flex-col h-full overflow-hidden w-full px-3 sm:px-4 md:px-5 lg:px-8 py-3 sm:py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
|
||||
>
|
||||
<div class="flex flex-col mb-4 w-full max-w-6xl mx-auto space-y-4 min-w-0">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between min-w-0">
|
||||
@@ -131,7 +131,7 @@
|
||||
<div class="flex-1 overflow-hidden glass-card max-w-6xl mx-auto w-full p-0 flex flex-col rounded-sm">
|
||||
<div
|
||||
v-if="activeTab === 'logs'"
|
||||
class="flex-1 overflow-auto p-4 font-mono text-[10px] sm:text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
|
||||
class="flex-1 overflow-auto p-3 sm:p-4 font-mono text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
|
||||
>
|
||||
<div v-if="logs.length === 0" class="text-gray-500 italic text-center py-10">
|
||||
{{ loading ? $t("debug.loading_logs") : $t("debug.no_logs") }}
|
||||
@@ -139,14 +139,16 @@
|
||||
<div
|
||||
v-for="(log, index) in logs"
|
||||
:key="index"
|
||||
class="border-b border-gray-100 dark:border-zinc-900 py-1 flex gap-3 hover:bg-gray-50 dark:hover:bg-zinc-900/50"
|
||||
class="border-b border-gray-100 dark:border-zinc-900 py-1.5 flex gap-2 sm:gap-3 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
|
||||
:class="{ 'bg-red-50/30 dark:bg-red-900/10': log.is_anomaly }"
|
||||
title="Tap to copy this log entry"
|
||||
@click="copyLogLine(log)"
|
||||
>
|
||||
<span class="text-gray-400 shrink-0">{{ formatTime(log.timestamp) }}</span>
|
||||
<span :class="levelClass(log.level)" class="w-12 shrink-0 font-bold uppercase">{{
|
||||
log.level
|
||||
}}</span>
|
||||
<span class="text-blue-500 shrink-0 w-24 overflow-hidden text-ellipsis italic"
|
||||
<span class="text-blue-500 shrink-0 w-20 sm:w-24 overflow-hidden text-ellipsis italic"
|
||||
>[{{ log.module }}]</span
|
||||
>
|
||||
<span class="text-gray-800 dark:text-gray-200 break-words flex-1">
|
||||
@@ -164,7 +166,7 @@
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex-1 overflow-auto p-4 font-mono text-[10px] sm:text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
|
||||
class="flex-1 overflow-auto p-3 sm:p-4 font-mono text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
|
||||
>
|
||||
<div v-if="accessAttempts.length === 0" class="text-gray-500 italic text-center py-10">
|
||||
{{ accessLoading ? $t("debug.loading_access") : $t("debug.no_access") }}
|
||||
@@ -172,9 +174,11 @@
|
||||
<div
|
||||
v-for="row in accessAttempts"
|
||||
:key="row.id"
|
||||
class="border-b border-gray-100 dark:border-zinc-900 py-2 flex flex-col gap-1 hover:bg-gray-50 dark:hover:bg-zinc-900/50"
|
||||
class="border-b border-gray-100 dark:border-zinc-900 py-2 flex flex-col gap-1 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
|
||||
title="Tap to copy this access entry"
|
||||
@click="copyAccessLine(row)"
|
||||
>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 items-center">
|
||||
<span class="text-gray-400 shrink-0">{{ formatTime(row.created_at) }}</span>
|
||||
<span class="text-amber-600 dark:text-amber-400 font-semibold">{{ row.outcome }}</span>
|
||||
<span class="text-cyan-600 dark:text-cyan-400">{{ row.method }} {{ row.path }}</span>
|
||||
@@ -441,6 +445,32 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
async copyLogLine(log) {
|
||||
const line = `${this.formatTime(log.timestamp)} [${log.level}] [${log.module}] ${log.message}${log.is_anomaly ? " [ANOMALY:" + (log.anomaly_type || "unknown") + "]" : ""}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(line);
|
||||
ToastUtils.success(this.$t("debug.logs_copied"));
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("debug.failed_copy_logs"));
|
||||
}
|
||||
},
|
||||
async copyAccessLine(row) {
|
||||
const line = [
|
||||
this.formatTime(row.created_at),
|
||||
row.outcome,
|
||||
row.method,
|
||||
row.path,
|
||||
row.client_ip,
|
||||
row.user_agent || "",
|
||||
row.detail || "",
|
||||
].join(" | ");
|
||||
try {
|
||||
await navigator.clipboard.writeText(line);
|
||||
ToastUtils.success(this.$t("debug.access_copied"));
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("debug.failed_copy_access"));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -4727,7 +4727,7 @@ export default {
|
||||
|
||||
// alert if failed to start recording
|
||||
if (!this.isRecordingAudioAttachment) {
|
||||
DialogUtils.alert(this.$t("messages.failed_start_recording"));
|
||||
DialogUtils.alert(this.buildAudioRecordingFailureMessage());
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -4749,7 +4749,7 @@ export default {
|
||||
|
||||
// alert if failed to start recording
|
||||
if (!this.isRecordingAudioAttachment) {
|
||||
DialogUtils.alert(this.$t("messages.failed_start_recording"));
|
||||
DialogUtils.alert(this.buildAudioRecordingFailureMessage());
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -4843,6 +4843,15 @@ export default {
|
||||
// remove audio
|
||||
this.newMessageAudio = null;
|
||||
},
|
||||
buildAudioRecordingFailureMessage() {
|
||||
if (!navigator?.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
|
||||
return `${this.$t("messages.failed_start_recording")} (microphone API unavailable in this webview context)`;
|
||||
}
|
||||
if (typeof MediaRecorder !== "function") {
|
||||
return `${this.$t("messages.failed_start_recording")} (MediaRecorder not supported on this device)`;
|
||||
}
|
||||
return this.$t("messages.failed_start_recording");
|
||||
},
|
||||
removeFileAttachment: function (file) {
|
||||
this.newMessageFiles = this.newMessageFiles.filter((newMessageFile) => {
|
||||
return newMessageFile !== file;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
v-if="config"
|
||||
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6">
|
||||
<div class="space-y-0 w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto">
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden w-full min-w-0 px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6">
|
||||
<div class="space-y-0 w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto min-w-0">
|
||||
<div class="settings-section settings-section--hero">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center gap-4">
|
||||
<div class="flex-1 space-y-1">
|
||||
@@ -98,9 +98,9 @@
|
||||
|
||||
<!-- search bar -->
|
||||
<div
|
||||
class="sticky top-0 z-10 py-3 sm:py-4 -mx-3 sm:mx-0 px-3 sm:px-0 mb-2 border-b border-gray-200/50 dark:border-zinc-800/50 bg-transparent"
|
||||
class="sticky top-0 z-10 py-3 sm:py-4 mb-2 border-b border-gray-200/50 dark:border-zinc-800/50 bg-transparent min-w-0"
|
||||
>
|
||||
<div class="relative w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto">
|
||||
<div class="relative w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto min-w-0 px-0">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
@@ -271,4 +271,35 @@ describe("AboutPage.vue", () => {
|
||||
expect(wrapper.text()).toContain("Free Space");
|
||||
expect(wrapper.text()).toContain("0 Bytes");
|
||||
});
|
||||
|
||||
it("shows unknown fallbacks for missing environment paths", async () => {
|
||||
axiosMock.get.mockImplementation((url) => {
|
||||
if (url === "/api/v1/app/info")
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
app_info: {
|
||||
version: "1.0.0",
|
||||
python_version: "3.11.0",
|
||||
lxmf_version: "0.2.0",
|
||||
rns_version: "0.1.0",
|
||||
reticulum_config_path: null,
|
||||
database_path: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
|
||||
if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
|
||||
if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
|
||||
return Promise.reject(new Error("Not found"));
|
||||
});
|
||||
|
||||
const wrapper = mountAboutPage();
|
||||
await vi.runOnlyPendingTimers();
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain("Reticulum Config");
|
||||
expect(wrapper.text()).toContain("Database Path");
|
||||
expect(wrapper.text()).toContain("unknown");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,11 @@ window.api = {
|
||||
describe("DebugLogsPage.vue", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches and displays logs", async () => {
|
||||
@@ -44,7 +49,7 @@ describe("DebugLogsPage.vue", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const container = wrapper.find(".flex-1.overflow-auto.p-4.font-mono");
|
||||
const container = wrapper.find(".flex-1.overflow-auto.font-mono");
|
||||
const logRows = container.findAll(".border-b");
|
||||
expect(logRows.length).toBe(2);
|
||||
expect(wrapper.text()).toContain("Hello");
|
||||
@@ -160,4 +165,37 @@ describe("DebugLogsPage.vue", () => {
|
||||
expect(wrapper.text()).toContain("failed_password");
|
||||
expect(wrapper.text()).toContain("10.0.0.1");
|
||||
});
|
||||
|
||||
it("copies a single log line when tapping an entry", async () => {
|
||||
const mockLogs = [
|
||||
{ timestamp: Date.now() / 1000, level: "ERROR", module: "web_protocol", message: "Boom", is_anomaly: 0 },
|
||||
];
|
||||
|
||||
window.api.get.mockResolvedValue({
|
||||
data: {
|
||||
logs: mockLogs,
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(DebugLogsPage, {
|
||||
global: {
|
||||
stubs: ["MaterialDesignIcon"],
|
||||
mocks: { $t: (key) => key },
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const row = wrapper.findAll(".border-b").find((r) => r.text().includes("Boom"));
|
||||
expect(row).toBeTruthy();
|
||||
await row.trigger("click");
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||
expect(navigator.clipboard.writeText.mock.calls[0][0]).toContain("[ERROR]");
|
||||
expect(navigator.clipboard.writeText.mock.calls[0][0]).toContain("Boom");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user