mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
fix frontend notification permission refresh (#3768)
This commit is contained in:
parent
2e789eae18
commit
69cf4f4d86
@ -54,6 +54,7 @@ export function NotificationSettingsPage() {
|
|||||||
<div>{t.settings.notification.description}</div>
|
<div>{t.settings.notification.description}</div>
|
||||||
<div>
|
<div>
|
||||||
<Switch
|
<Switch
|
||||||
|
aria-label={t.settings.notification.title}
|
||||||
disabled={permission !== "granted"}
|
disabled={permission !== "granted"}
|
||||||
checked={
|
checked={
|
||||||
permission === "granted" && settings.notification.enabled
|
permission === "granted" && settings.notification.enabled
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export function useNotification(): UseNotificationReturn {
|
|||||||
useState<NotificationPermission>("default");
|
useState<NotificationPermission>("default");
|
||||||
const [isSupported, setIsSupported] = useState(false);
|
const [isSupported, setIsSupported] = useState(false);
|
||||||
|
|
||||||
const lastNotificationTime = useRef<Date>(new Date());
|
const lastNotificationTime = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if browser supports Notification API
|
// Check if browser supports Notification API
|
||||||
@ -60,19 +60,25 @@ export function useNotification(): UseNotificationReturn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentPermission = Notification.permission;
|
||||||
|
if (currentPermission !== permission) {
|
||||||
|
setPermission(currentPermission);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPermission !== "granted") {
|
||||||
|
console.warn("Notification permission not granted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
if (
|
if (
|
||||||
new Date().getTime() - lastNotificationTime.current.getTime() <
|
lastNotificationTime.current !== null &&
|
||||||
1000
|
now - lastNotificationTime.current < 1000
|
||||||
) {
|
) {
|
||||||
console.warn("Notification sent too soon");
|
console.warn("Notification sent too soon");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastNotificationTime.current = new Date();
|
lastNotificationTime.current = now;
|
||||||
|
|
||||||
if (permission !== "granted") {
|
|
||||||
console.warn("Notification permission not granted");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const notification = new Notification(title, options);
|
const notification = new Notification(title, options);
|
||||||
|
|
||||||
|
|||||||
115
frontend/tests/e2e/settings-notification.spec.ts
Normal file
115
frontend/tests/e2e/settings-notification.spec.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__deerflowNotifications?: Array<{ title: string; body?: string }>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installNotificationMock(
|
||||||
|
page: Page,
|
||||||
|
initialPermission: NotificationPermission = "default",
|
||||||
|
) {
|
||||||
|
await page.addInitScript((permission) => {
|
||||||
|
window.__deerflowNotifications = [];
|
||||||
|
|
||||||
|
class MockNotification {
|
||||||
|
static permission: NotificationPermission = permission;
|
||||||
|
|
||||||
|
static async requestPermission(): Promise<NotificationPermission> {
|
||||||
|
MockNotification.permission = "granted";
|
||||||
|
return "granted";
|
||||||
|
}
|
||||||
|
|
||||||
|
onclick: (() => void) | null = null;
|
||||||
|
onerror: ((error: Event) => void) | null = null;
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
constructor(title: string, options?: NotificationOptions) {
|
||||||
|
window.__deerflowNotifications?.push({
|
||||||
|
title,
|
||||||
|
body: options?.body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, "Notification", {
|
||||||
|
configurable: true,
|
||||||
|
value: MockNotification,
|
||||||
|
});
|
||||||
|
}, initialPermission);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNotificationSettings(page: Page) {
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
const sidebar = page.locator("[data-sidebar='sidebar']");
|
||||||
|
await sidebar.getByRole("button", { name: /Settings and more/ }).click();
|
||||||
|
await page.getByRole("menuitem", { name: "Settings" }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Settings" });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole("button", { name: "Notification" }).click();
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Notification settings", () => {
|
||||||
|
test("can request permission and send the first test notification immediately", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page);
|
||||||
|
await installNotificationMock(page);
|
||||||
|
|
||||||
|
const dialog = await openNotificationSettings(page);
|
||||||
|
await dialog
|
||||||
|
.getByRole("button", { name: "Request notification permission" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dialog.getByRole("switch", { name: "Notification" }),
|
||||||
|
).toBeChecked();
|
||||||
|
|
||||||
|
await dialog
|
||||||
|
.getByRole("button", { name: "Send test notification" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.__deerflowNotifications ?? []))
|
||||||
|
.toEqual([
|
||||||
|
{
|
||||||
|
title: "DeerFlow",
|
||||||
|
body: "This is a test notification.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends a completion notification when chat finishes while the page is unfocused", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page);
|
||||||
|
await installNotificationMock(page, "granted");
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Document.prototype.hasFocus = () => false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||||
|
await textarea.fill("Run a one minute task");
|
||||||
|
await textarea.press("Enter");
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.__deerflowNotifications ?? []))
|
||||||
|
.toEqual([
|
||||||
|
{
|
||||||
|
title: "New Chat",
|
||||||
|
body: "Hello from DeerFlow!",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
frontend/tests/unit/core/notification/hooks.test.ts
Normal file
123
frontend/tests/unit/core/notification/hooks.test.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { afterEach, describe, expect, test, rs } from "@rstest/core";
|
||||||
|
|
||||||
|
type NotificationInstance = {
|
||||||
|
onclick: (() => void) | null;
|
||||||
|
onerror: ((error: Event) => void) | null;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadNotificationHook({
|
||||||
|
browserPermission = "granted",
|
||||||
|
hookPermission = "granted",
|
||||||
|
}: {
|
||||||
|
browserPermission?: NotificationPermission;
|
||||||
|
hookPermission?: NotificationPermission;
|
||||||
|
} = {}) {
|
||||||
|
const notifications: Array<{
|
||||||
|
title: string;
|
||||||
|
options: NotificationOptions | undefined;
|
||||||
|
instance: NotificationInstance;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
class MockNotification implements NotificationInstance {
|
||||||
|
static permission: NotificationPermission = browserPermission;
|
||||||
|
static requestPermission = rs.fn(async () => {
|
||||||
|
MockNotification.permission = "granted";
|
||||||
|
return "granted" as const;
|
||||||
|
});
|
||||||
|
|
||||||
|
onclick: (() => void) | null = null;
|
||||||
|
onerror: ((error: Event) => void) | null = null;
|
||||||
|
close = rs.fn();
|
||||||
|
|
||||||
|
constructor(title: string, options?: NotificationOptions) {
|
||||||
|
notifications.push({ title, options, instance: this });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rs.resetModules();
|
||||||
|
rs.doMock("react", () => ({
|
||||||
|
useCallback: <T extends (...args: never[]) => unknown>(callback: T) =>
|
||||||
|
callback,
|
||||||
|
useEffect: () => undefined,
|
||||||
|
useRef: <T>(initialValue: T) => ({ current: initialValue }),
|
||||||
|
useState: <T>(initialValue: T) => {
|
||||||
|
if (initialValue === "default") {
|
||||||
|
return [hookPermission, rs.fn()] as const;
|
||||||
|
}
|
||||||
|
if (initialValue === false) {
|
||||||
|
return [true, rs.fn()] as const;
|
||||||
|
}
|
||||||
|
return [initialValue, rs.fn()] as const;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
rs.doMock("@/core/settings", () => ({
|
||||||
|
useLocalSettings: () => [
|
||||||
|
{
|
||||||
|
notification: { enabled: true },
|
||||||
|
tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
|
||||||
|
context: {
|
||||||
|
model_name: undefined,
|
||||||
|
mode: undefined,
|
||||||
|
reasoning_effort: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rs.fn(),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
rs.stubGlobal("window", { focus: rs.fn() });
|
||||||
|
rs.stubGlobal("Notification", MockNotification);
|
||||||
|
|
||||||
|
const { useNotification } = await import("@/core/notification/hooks");
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
useNotification,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rs.doUnmock("react");
|
||||||
|
rs.doUnmock("@/core/settings");
|
||||||
|
rs.unstubAllGlobals();
|
||||||
|
rs.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useNotification", () => {
|
||||||
|
test("allows the first notification immediately after the hook is created", async () => {
|
||||||
|
const { notifications, useNotification } = await loadNotificationHook();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
|
showNotification("Finished", { body: "Conversation finished" });
|
||||||
|
|
||||||
|
expect(notifications).toHaveLength(1);
|
||||||
|
expect(notifications[0]?.title).toBe("Finished");
|
||||||
|
expect(notifications[0]?.options?.body).toBe("Conversation finished");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rate limits only after a notification has been sent", async () => {
|
||||||
|
const { notifications, useNotification } = await loadNotificationHook();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
|
showNotification("First");
|
||||||
|
showNotification("Second");
|
||||||
|
|
||||||
|
expect(notifications.map((notification) => notification.title)).toEqual([
|
||||||
|
"First",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the browser's current permission when another hook requested it", async () => {
|
||||||
|
const { notifications, useNotification } = await loadNotificationHook({
|
||||||
|
browserPermission: "granted",
|
||||||
|
hookPermission: "default",
|
||||||
|
});
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
|
showNotification("Finished elsewhere");
|
||||||
|
|
||||||
|
expect(notifications.map((notification) => notification.title)).toEqual([
|
||||||
|
"Finished elsewhere",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user