Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions src/app/NotificationSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { Context } from "@deepseek-ai/cordis";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { PluginRuntime } from "../plugins/runtime";
import { provideNavigation } from "../features/navigation/service";
import { createBrowserNotifications } from "../features/notifications/platform";
import { createNotificationPreferences } from "../features/notifications/preferences";
import { NotificationsService } from "../features/notifications/service";
import { NotificationSettings } from "./NotificationSettings";

const contexts: Context[] = [];
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
});
afterEach(async () => {
cleanup();
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose();
vi.useRealTimers();
vi.unstubAllGlobals();
});

async function setup(permission: NotificationPermission) {
const shown: FakeNotification[] = [];
class FakeNotification {
static permission = permission;
static requestPermission = vi.fn(async () => FakeNotification.permission);
onclick: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
close = vi.fn();
constructor() {
shown.push(this);
}
}
vi.stubGlobal("Notification", FakeNotification);
const ctx = new Context();
contexts.push(ctx);
const runtime = new PluginRuntime(ctx, async () => ({ apply() {} }));
ctx.effect(() => () => runtime.dispose());
const service = new NotificationsService(
ctx,
provideNavigation(ctx).navigation,
createBrowserNotifications(window),
createNotificationPreferences(window),
);
service.selectViewer("a".repeat(64));
await service.refreshPermission();
render(<NotificationSettings notifications={service} />, {
reactStrictMode: true,
});
return {
service,
shown,
FakeNotification,
async submit(sourceKey: string) {
let accepted = false;
await act(async () => {
accepted = await service.admit(
"mention",
"Mentions",
{ sourceKey, target: { version: 1, kind: "settings" } },
() => true,
);
});
return accepted;
},
};
}

// Complete the presentation scheduler before asserting absence of delivery.
const present = () => act(() => vi.advanceTimersByTimeAsync(100));

it("Allow releases a pending alert and master toggles preserve category choices", async () => {
const h = await setup("default");
expect(await h.submit("pending")).toBe(true);
await present();
expect(h.shown).toHaveLength(0);
expect(h.FakeNotification.requestPermission).not.toHaveBeenCalled();
let grant!: (permission: NotificationPermission) => void;
h.FakeNotification.requestPermission.mockImplementationOnce(
() =>
new Promise((resolve) => {
grant = resolve;
}),
);
fireEvent.click(screen.getByRole("button", { name: "Allow notifications" }));
try {
expect(h.FakeNotification.requestPermission).toHaveBeenCalledOnce();
expect(screen.getByRole("status")).toHaveTextContent(
"Waiting for system permission",
);
expect(
screen.getByRole("button", { name: "Allow notifications" }),
).toBeDisabled();
expect(
screen.getByRole("button", { name: "Check permission" }),
).toBeDisabled();
await present();
expect(h.shown).toHaveLength(0);
} finally {
await act(async () => {
h.FakeNotification.permission = "granted";
grant("granted");
});
}
await present();
expect(h.shown).toHaveLength(1);
expect(screen.getByRole("status")).toHaveTextContent("Permission granted");
fireEvent.click(screen.getByRole("switch", { name: "Mentions" }));
fireEvent.click(screen.getByRole("switch", { name: "Desktop alerts" }));
fireEvent.click(screen.getByRole("switch", { name: "Desktop alerts" }));
expect(screen.getByRole("switch", { name: "Desktop alerts" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Mentions" })).not.toBeChecked();
expect(await h.submit("muted")).toBe(false);
await present();
expect(h.shown).toHaveLength(1);
});

it("browser display errors reach mounted Settings and retire callbacks without redelivery", async () => {
const h = await setup("granted");
expect(await h.submit("failed")).toBe(true);
await present();
expect(h.shown).toHaveLength(1);
const item = h.shown[0];
if (!item) throw new Error("No notification was displayed");
act(() => item.onerror?.());
expect(screen.getByRole("alert")).toHaveTextContent(
"The browser could not display a notification.",
);
expect(item.close).toHaveBeenCalledOnce();
expect(item.onclick).toBeNull();
expect(item.onerror).toBeNull();
expect(item.onclose).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Check permission" }));
expect(await h.submit("failed")).toBe(false);
await present();
expect(h.shown).toHaveLength(1);
});
71 changes: 71 additions & 0 deletions src/bundled/channels/UnreadBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StrictMode } from "react";
import { afterEach, expect, it, vi } from "vitest";
import type { RelaySession } from "../../features/relay/session";
import { UnreadOptions } from "./UnreadBadge";

afterEach(cleanup);

function session(rows: { id: string; membership?: unknown }[]) {
const markThrough = vi.fn().mockResolvedValue(undefined);
const sync = {
capability: "frontier-sync",
status: "reconciled",
error: null,
} as const;
const unread = {
subscribeSync: () => () => {},
sync: () => sync,
markUnreadLocal: vi.fn().mockResolvedValue(undefined),
markThrough,
refresh: vi.fn().mockResolvedValue(undefined),
retrySync: vi.fn().mockResolvedValue(undefined),
};
return {
value: {
unread,
channels: { window: () => ({ rows }) },
} as unknown as RelaySession,
markThrough,
};
}

it("marks through the newest verified message, never trailing membership activity", async () => {
const h = session([
{ id: "older" },
{ id: "newest-chat" },
{ id: "membership", membership: {} },
]);
render(
<StrictMode>
<UnreadOptions session={h.value} channelId="room" />
</StrictMode>,
);
await userEvent.click(
screen.getByRole("button", { name: "Mark read through loaded messages" }),
);
expect(h.markThrough).toHaveBeenCalledExactlyOnceWith(
{ kind: "channel", channelId: "room" },
"newest-chat",
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});

it("keeps manual intent when only membership activity is loaded", async () => {
const h = session([{ id: "membership", membership: {} }]);
render(
<StrictMode>
<UnreadOptions session={h.value} channelId="room" />
</StrictMode>,
);
await userEvent.click(
screen.getByRole("button", { name: "Mark read through loaded messages" }),
);
expect(h.markThrough).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
"Load a verified message before marking through it.",
);
});
87 changes: 85 additions & 2 deletions src/features/messages/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import type { RelaySession } from "../relay/session";
import { emojiMatches, type CustomEmoji } from "../relay/emoji";
import { CustomEmoji as CustomEmojiImage } from "../../bundled/emoji/CustomEmoji";
import type { ComposerInputElement } from "./composer-dom";
import { mentionQuery } from "../../bundled/mentions/mention-query";
import { emojiQuery } from "../../bundled/emoji/emoji-query";

const first = { pubkey: "a".repeat(64), name: "Honey" };
const second = { pubkey: "b".repeat(64), name: "Honey" };
Expand All @@ -46,7 +48,10 @@ afterEach(() => {
delete (HTMLElement.prototype as Partial<HTMLElement>).scrollIntoView;
});

function mount(options: Partial<MessageComposerProps> = {}) {
function mount(
options: Partial<MessageComposerProps> = {},
providers?: readonly Contribution<ComposerCompletion>[],
) {
let commands: ComposerToolProps;
const completionRequests: ComposerCompletionProps["publish"][] = [];
function Completion({ publish }: ComposerCompletionProps) {
Expand All @@ -68,7 +73,7 @@ function mount(options: Partial<MessageComposerProps> = {}) {
: null,
component: Completion,
});
let completions: readonly Contribution<ComposerCompletion>[] = [
let completions: readonly Contribution<ComposerCompletion>[] = providers ?? [
completion("1"),
];
function Tool(props: ComposerToolProps) {
Expand Down Expand Up @@ -236,6 +241,11 @@ it("revokes stale completion publications across editor and ownership lifecycles
expect(h.publish(afterAba, "stale dismissal")).toBe(false);
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();

h.fill("!blur");
const blurred = h.completionRequests.length - 1;
act(() => input.blur());
expect(h.publish(blurred, "stale blur")).toBe(false);

h.fill("!provider");
const oldProvider = h.completionRequests.length - 1;
h.replaceCompletionProvider();
Expand All @@ -261,6 +271,79 @@ it("revokes stale completion publications across editor and ownership lifecycles
expect(h.publish(fresh, "stale unmount")).toBe(false);
});

it.each([undefined, "root"])(
"completion preserves surrounding prose and exact recipient intent through emoji for %s",
async (threadRootId) => {
function Mentions({ publish }: ComposerCompletionProps) {
useLayoutEffect(() => {
return (
publish({
items: [first, second].map((mention) => ({
id: mention.pubkey,
label: mention.pubkey,
edit: { mention },
})),
}) || undefined
);
}, [publish]);
return null;
}
function Emoji({ publish }: ComposerCompletionProps) {
useLayoutEffect(() => {
return (
publish({
items: [{ id: "smile", label: "Smile", edit: { text: "πŸ˜„" } }],
}) || undefined
);
}, [publish]);
return null;
}
const h = mount(threadRootId ? { threadRootId } : {}, [
{
id: "mentions",
key: "mentions",
pluginId: "test",
revision: "1",
title: "Mentions",
order: -10,
match: ({ text, start }) => mentionQuery(text, start),
component: Mentions,
},
{
id: "emoji",
key: "emoji",
pluginId: "test",
revision: "1",
title: "Emoji",
match: ({ text, start }) => emojiQuery(text, start),
component: Emoji,
},
]);
h.fill("Before @Ho after");
act(() => h.input().setSelectionRange(10, 10));
fireEvent.select(h.input());
await h.user.click(screen.getByRole("option", { name: second.pubkey }));
expect(h.input()).toHaveValue("Before @Honey after");
expect(h.input()).toHaveFocus();
act(() => h.input().setSelectionRange(20, 20));
fireEvent.select(h.input());
await h.user.keyboard(" :smile");
await h.user.keyboard("{Tab}");
expect(h.input()).toHaveValue("Before @Honey after πŸ˜„");
h.submit();
const send = threadRootId ? h.messages.reply : h.messages.send;
expect(send.mock.calls[0]?.slice(-2)).toEqual([
"Before @Honey after πŸ˜„",
[second.pubkey],
]);
h.fill("@Honey :smile");
await h.user.keyboard("{Tab}");
expect(h.input()).toHaveValue("@Honey πŸ˜„");
h.submit();
expect(send.mock.calls[1]?.slice(-2)).toEqual(["@Honey πŸ˜„", []]);
},
);

it.each(["disabled", "readOnly"] as const)(
"rejects late and displayed completion results when the editor becomes %s",
(state) => {
Expand Down
Loading
Loading