mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-13 02:39:42 +02:00
feat(notifications): configurable toast and browser-notification display time (#564)
Squash-merged so the toast-history half, dropped during review, stays out of master's history.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'aicodeman': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Notifications stay as long as you want. Settings → Notifications has a "Toast display time" and a "Browser notification display time" (seconds, per device; the defaults stay 3s and 8s).
|
||||||
@@ -146,7 +146,11 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E
|
|||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
Master toggle, browser notifications, push subscription, audio alerts, the idle
|
Master toggle, browser notifications, push subscription, audio alerts, how long a
|
||||||
|
corner toast stays on screen (**Toast display time**, 1 to 300 seconds, default 3) and
|
||||||
|
how long a desktop notification stays up before Codeman closes it (**Browser
|
||||||
|
notification display time**, default 8; both per device, and your OS may close a
|
||||||
|
desktop notification sooner), the idle
|
||||||
threshold that decides when a quiet session counts as needing you, and the server-wide
|
threshold that decides when a quiet session counts as needing you, and the server-wide
|
||||||
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See
|
||||||
[Notifications And Approvals](Notifications-And-Approvals).
|
[Notifications And Approvals](Notifications-And-Approvals).
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
|
|||||||
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
|
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
|
||||||
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
|
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
|
||||||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||||||
|
const DEFAULT_TOAST_DURATION_MS = 3000; // How long a corner toast stays by default
|
||||||
|
const MIN_NOTIFICATION_DURATION_MS = 1000; // Shortest configurable toast / browser-notification time
|
||||||
|
const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / browser-notification time
|
||||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
||||||
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
||||||
|
|||||||
@@ -2723,6 +2723,26 @@
|
|||||||
<span class="set-unit">min</span>
|
<span class="set-unit">min</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row" data-search="toast display time popup dismiss seconds notification">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Toast display time</span>
|
||||||
|
<span class="set-row-desc">How long the corner pop-ups stay on screen.</span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row-actions">
|
||||||
|
<input type="number" id="appSettingsNotifToastSecs" class="set-num" value="3" min="1" max="300">
|
||||||
|
<span class="set-unit">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" data-search="browser notification auto close dismiss seconds">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Browser notification display time</span>
|
||||||
|
<span class="set-row-desc">How long a desktop notification stays up before Codeman closes it. Your OS may close it sooner.</span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row-actions">
|
||||||
|
<input type="number" id="appSettingsNotifBrowserSecs" class="set-num" value="8" min="1" max="300">
|
||||||
|
<span class="set-unit">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* The NotificationManager class implements five notification layers:
|
* The NotificationManager class implements five notification layers:
|
||||||
* 1. In-app notification drawer (slide-out panel with grouped notifications)
|
* 1. In-app notification drawer (slide-out panel with grouped notifications)
|
||||||
* 2. Tab title flash (alternating "⚠️ (N) codeman:<host>" / "codeman:<host>" when tab is hidden; uses this.originalTitle so it tracks any per-host title)
|
* 2. Tab title flash (alternating "⚠️ (N) codeman:<host>" / "codeman:<host>" when tab is hidden; uses this.originalTitle so it tracks any per-host title)
|
||||||
* 3. Browser Notification API (desktop push with auto-close after 8s)
|
* 3. Browser Notification API (desktop push; auto-closes after 8s by default, configurable per device in Settings → Notifications)
|
||||||
* 4. Web Push via service worker (OS-level notifications when tab is closed)
|
* 4. Web Push via service worker (OS-level notifications when tab is closed)
|
||||||
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
||||||
*
|
*
|
||||||
@@ -93,6 +93,10 @@ class NotificationManager {
|
|||||||
browserNotifications: !isMobile,
|
browserNotifications: !isMobile,
|
||||||
audioAlerts: false,
|
audioAlerts: false,
|
||||||
stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS,
|
stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS,
|
||||||
|
// How long a corner toast stays on screen, and how long a browser notification
|
||||||
|
// stays up before Codeman closes it (ms; per-device like the rest of these)
|
||||||
|
toastDurationMs: DEFAULT_TOAST_DURATION_MS,
|
||||||
|
browserAutoCloseMs: AUTO_CLOSE_NOTIFICATION_MS,
|
||||||
// Legacy urgency muting (keep for backwards compat)
|
// Legacy urgency muting (keep for backwards compat)
|
||||||
muteCritical: false,
|
muteCritical: false,
|
||||||
muteWarning: false,
|
muteWarning: false,
|
||||||
@@ -167,11 +171,24 @@ class NotificationManager {
|
|||||||
return {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
...prefs,
|
...prefs,
|
||||||
|
toastDurationMs: this.clampDuration(prefs.toastDurationMs, defaults.toastDurationMs),
|
||||||
|
browserAutoCloseMs: this.clampDuration(prefs.browserAutoCloseMs, defaults.browserAutoCloseMs),
|
||||||
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
|
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
|
||||||
_version: 5,
|
_version: 5,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A display time in ms kept within [1s, 5min]; anything unusable falls back to the default. */
|
||||||
|
clampDuration(value, fallback) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||||
|
return Math.min(MAX_NOTIFICATION_DURATION_MS, Math.max(MIN_NOTIFICATION_DURATION_MS, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display time for corner toasts that do not set their own `duration`. */
|
||||||
|
getToastDurationMs() {
|
||||||
|
return this.clampDuration(this.preferences?.toastDurationMs, DEFAULT_TOAST_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
loadPreferences() {
|
loadPreferences() {
|
||||||
try {
|
try {
|
||||||
const storageKey = this.getStorageKey();
|
const storageKey = this.getStorageKey();
|
||||||
@@ -403,7 +420,7 @@ class NotificationManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Auto-close
|
// Auto-close
|
||||||
setTimeout(() => notif.close(), AUTO_CLOSE_NOTIFICATION_MS);
|
setTimeout(() => notif.close(), this.clampDuration(this.preferences.browserAutoCloseMs, AUTO_CLOSE_NOTIFICATION_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestPermission() {
|
async requestPermission() {
|
||||||
@@ -466,7 +483,7 @@ class NotificationManager {
|
|||||||
notif.read = true;
|
notif.read = true;
|
||||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||||
this.updateBadge();
|
this.updateBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to session if available
|
// Switch to session if available
|
||||||
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
|
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
|
||||||
|
|||||||
@@ -5862,7 +5862,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `duration` defaults to 3000ms for every toast type. A message worth
|
* `duration` defaults to the "Toast display time" preference (3000ms unless changed in
|
||||||
|
* Settings → Notifications) for every toast type. A message worth
|
||||||
* reading rather than glancing at (e.g. "Session started on the native
|
* reading rather than glancing at (e.g. "Session started on the native
|
||||||
* backend — could not apply the custom endpoint: <the actual reason>")
|
* backend — could not apply the custom endpoint: <the actual reason>")
|
||||||
* passes an explicit `opts.duration: 0` at its own call site instead of
|
* passes an explicit `opts.duration: 0` at its own call site instead of
|
||||||
@@ -5874,7 +5875,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* regardless of duration.
|
* regardless of duration.
|
||||||
*/
|
*/
|
||||||
showToast(message, type = 'info', opts = {}) {
|
showToast(message, type = 'info', opts = {}) {
|
||||||
const { duration = 3000, action } = opts;
|
const { duration = this.notificationManager?.getToastDurationMs?.() ?? 3000, action } = opts;
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast-${type}`;
|
toast.className = `toast toast-${type}`;
|
||||||
|
|
||||||
|
|||||||
@@ -563,6 +563,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false;
|
document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false;
|
||||||
document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false;
|
document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false;
|
||||||
document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000);
|
document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000);
|
||||||
|
document.getElementById('appSettingsNotifToastSecs').value = Math.round(
|
||||||
|
(this.notificationManager?.getToastDurationMs?.() ?? DEFAULT_TOAST_DURATION_MS) / 1000
|
||||||
|
);
|
||||||
|
document.getElementById('appSettingsNotifBrowserSecs').value = Math.round(
|
||||||
|
(notifPrefs.browserAutoCloseMs ?? AUTO_CLOSE_NOTIFICATION_MS) / 1000
|
||||||
|
);
|
||||||
document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical;
|
document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical;
|
||||||
document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning;
|
document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning;
|
||||||
document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo;
|
document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo;
|
||||||
@@ -2667,6 +2673,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
browserNotifications: document.getElementById('appSettingsNotifBrowser').checked,
|
browserNotifications: document.getElementById('appSettingsNotifBrowser').checked,
|
||||||
audioAlerts: document.getElementById('appSettingsNotifAudio').checked,
|
audioAlerts: document.getElementById('appSettingsNotifAudio').checked,
|
||||||
stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000,
|
stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000,
|
||||||
|
toastDurationMs: (parseInt(document.getElementById('appSettingsNotifToastSecs').value) || 3) * 1000,
|
||||||
|
browserAutoCloseMs: (parseInt(document.getElementById('appSettingsNotifBrowserSecs').value) || 8) * 1000,
|
||||||
muteCritical: !document.getElementById('appSettingsNotifCritical').checked,
|
muteCritical: !document.getElementById('appSettingsNotifCritical').checked,
|
||||||
muteWarning: !document.getElementById('appSettingsNotifWarning').checked,
|
muteWarning: !document.getElementById('appSettingsNotifWarning').checked,
|
||||||
muteInfo: !document.getElementById('appSettingsNotifInfo').checked,
|
muteInfo: !document.getElementById('appSettingsNotifInfo').checked,
|
||||||
@@ -2736,7 +2744,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_version: 5,
|
_version: 5,
|
||||||
};
|
};
|
||||||
if (this.notificationManager) {
|
if (this.notificationManager) {
|
||||||
this.notificationManager.preferences = notifPrefsToSave;
|
this.notificationManager.preferences = this.notificationManager.normalizePreferences(notifPrefsToSave);
|
||||||
this.notificationManager.savePreferences();
|
this.notificationManager.savePreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1517,6 +1517,8 @@ export const SettingsUpdateSchema = z
|
|||||||
browserNotifications: z.boolean().optional(),
|
browserNotifications: z.boolean().optional(),
|
||||||
audioAlerts: z.boolean().optional(),
|
audioAlerts: z.boolean().optional(),
|
||||||
stuckThresholdMs: z.number().optional(),
|
stuckThresholdMs: z.number().optional(),
|
||||||
|
toastDurationMs: z.number().optional(),
|
||||||
|
browserAutoCloseMs: z.number().optional(),
|
||||||
muteCritical: z.boolean().optional(),
|
muteCritical: z.boolean().optional(),
|
||||||
muteWarning: z.boolean().optional(),
|
muteWarning: z.boolean().optional(),
|
||||||
muteInfo: z.boolean().optional(),
|
muteInfo: z.boolean().optional(),
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ type Manager = {
|
|||||||
getStorageKey: () => string;
|
getStorageKey: () => string;
|
||||||
normalizePreferences: (preferences: Record<string, unknown>) => NotificationPreferences;
|
normalizePreferences: (preferences: Record<string, unknown>) => NotificationPreferences;
|
||||||
notify: (notification: Record<string, unknown>) => void;
|
notify: (notification: Record<string, unknown>) => void;
|
||||||
|
getToastDurationMs: () => number;
|
||||||
|
unreadCount: number;
|
||||||
|
markAllRead: () => void;
|
||||||
|
clearAll: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openWindows: JSDOM[] = [];
|
const openWindows: JSDOM[] = [];
|
||||||
@@ -48,6 +52,10 @@ function loadManager(
|
|||||||
STUCK_THRESHOLD_DEFAULT_MS: number;
|
STUCK_THRESHOLD_DEFAULT_MS: number;
|
||||||
GROUPING_TIMEOUT_MS: number;
|
GROUPING_TIMEOUT_MS: number;
|
||||||
NOTIFICATION_LIST_CAP: number;
|
NOTIFICATION_LIST_CAP: number;
|
||||||
|
AUTO_CLOSE_NOTIFICATION_MS: number;
|
||||||
|
DEFAULT_TOAST_DURATION_MS: number;
|
||||||
|
MIN_NOTIFICATION_DURATION_MS: number;
|
||||||
|
MAX_NOTIFICATION_DURATION_MS: number;
|
||||||
};
|
};
|
||||||
win.MobileDetection = {
|
win.MobileDetection = {
|
||||||
getDeviceType: () => device.deviceType ?? 'desktop',
|
getDeviceType: () => device.deviceType ?? 'desktop',
|
||||||
@@ -56,6 +64,10 @@ function loadManager(
|
|||||||
win.STUCK_THRESHOLD_DEFAULT_MS = 600_000;
|
win.STUCK_THRESHOLD_DEFAULT_MS = 600_000;
|
||||||
win.GROUPING_TIMEOUT_MS = 5_000;
|
win.GROUPING_TIMEOUT_MS = 5_000;
|
||||||
win.NOTIFICATION_LIST_CAP = 100;
|
win.NOTIFICATION_LIST_CAP = 100;
|
||||||
|
win.AUTO_CLOSE_NOTIFICATION_MS = 8_000;
|
||||||
|
win.DEFAULT_TOAST_DURATION_MS = 3_000;
|
||||||
|
win.MIN_NOTIFICATION_DURATION_MS = 1_000;
|
||||||
|
win.MAX_NOTIFICATION_DURATION_MS = 300_000;
|
||||||
win.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
win.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
callback(0);
|
callback(0);
|
||||||
return 1;
|
return 1;
|
||||||
@@ -66,6 +78,7 @@ function loadManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
win.eval(`
|
win.eval(`
|
||||||
|
window.escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';');
|
||||||
${SOURCE}
|
${SOURCE}
|
||||||
window.__testNotificationManager = NotificationManager;
|
window.__testNotificationManager = NotificationManager;
|
||||||
`);
|
`);
|
||||||
@@ -148,3 +161,23 @@ describe('notification noise defaults', () => {
|
|||||||
expect(manager.getStorageKey()).toBe('codeman-notification-prefs-mobile');
|
expect(manager.getStorageKey()).toBe('codeman-notification-prefs-mobile');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('notification display time', () => {
|
||||||
|
it('defaults to 3s toasts and 8s browser notifications', () => {
|
||||||
|
const { manager } = loadManager();
|
||||||
|
expect(manager.getToastDurationMs()).toBe(3000);
|
||||||
|
expect((manager.preferences as unknown as Record<string, number>).browserAutoCloseMs).toBe(8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a configured toast time and clamps unusable values', () => {
|
||||||
|
const { manager } = loadManager({ toastDurationMs: 15_000 });
|
||||||
|
expect(manager.getToastDurationMs()).toBe(15_000);
|
||||||
|
|
||||||
|
const clamp = (value: unknown) =>
|
||||||
|
(manager.normalizePreferences({ toastDurationMs: value }) as unknown as Record<string, number>).toastDurationMs;
|
||||||
|
expect(clamp(10)).toBe(1000);
|
||||||
|
expect(clamp(9_999_999)).toBe(300_000);
|
||||||
|
expect(clamp('soon')).toBe(3000);
|
||||||
|
expect(clamp(undefined)).toBe(3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview showToast() display time and drawer logging: a toast with no explicit
|
||||||
|
* `duration` uses the notification preference, an explicit `duration` (0 = sticky) still wins,
|
||||||
|
* and every toast is recorded in the notification drawer.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const windows: JSDOM[] = [];
|
||||||
|
|
||||||
|
function loadApp(notificationManager?: unknown) {
|
||||||
|
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/' });
|
||||||
|
windows.push(dom);
|
||||||
|
const CodemanApp = function CodemanApp() {} as unknown as { prototype: Record<string, unknown> };
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp,
|
||||||
|
document: dom.window.document,
|
||||||
|
requestAnimationFrame: (cb: () => void) => cb(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
|
||||||
|
vm.runInContext(source, context, { filename: 'panels-ui.js' });
|
||||||
|
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||||
|
app.notificationManager = notificationManager;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
for (const dom of windows.splice(0)) dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showToast', () => {
|
||||||
|
it('uses the configured display time when no duration is given', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const app = loadApp({ getToastDurationMs: () => 10_000 });
|
||||||
|
app.showToast('hello');
|
||||||
|
expect(windows[0].window.document.querySelectorAll('.toast')).toHaveLength(1);
|
||||||
|
vi.advanceTimersByTime(9_000);
|
||||||
|
expect(windows[0].window.document.querySelector('.toast.show')).not.toBeNull();
|
||||||
|
vi.advanceTimersByTime(1_500);
|
||||||
|
expect(windows[0].window.document.querySelector('.toast.show')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to 3s without a notification manager', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const app = loadApp();
|
||||||
|
app.showToast('hello');
|
||||||
|
vi.advanceTimersByTime(3_100);
|
||||||
|
expect(windows[0].window.document.querySelector('.toast.show')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an explicit duration of 0 stay until dismissed', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const app = loadApp({ getToastDurationMs: () => 1_000 });
|
||||||
|
app.showToast('sticky', 'error', { duration: 0 });
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(windows[0].window.document.querySelector('.toast.show')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user