mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 13:38:05 +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>
|
||||
<Switch
|
||||
aria-label={t.settings.notification.title}
|
||||
disabled={permission !== "granted"}
|
||||
checked={
|
||||
permission === "granted" && settings.notification.enabled
|
||||
|
||||
@ -24,7 +24,7 @@ export function useNotification(): UseNotificationReturn {
|
||||
useState<NotificationPermission>("default");
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
|
||||
const lastNotificationTime = useRef<Date>(new Date());
|
||||
const lastNotificationTime = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if browser supports Notification API
|
||||
@ -60,19 +60,25 @@ export function useNotification(): UseNotificationReturn {
|
||||
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 (
|
||||
new Date().getTime() - lastNotificationTime.current.getTime() <
|
||||
1000
|
||||
lastNotificationTime.current !== null &&
|
||||
now - lastNotificationTime.current < 1000
|
||||
) {
|
||||
console.warn("Notification sent too soon");
|
||||
return;
|
||||
}
|
||||
lastNotificationTime.current = new Date();
|
||||
|
||||
if (permission !== "granted") {
|
||||
console.warn("Notification permission not granted");
|
||||
return;
|
||||
}
|
||||
lastNotificationTime.current = now;
|
||||
|
||||
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