diff --git a/src/app/NotificationSettings.test.tsx b/src/app/NotificationSettings.test.tsx new file mode 100644 index 00000000..898b2ea5 --- /dev/null +++ b/src/app/NotificationSettings.test.tsx @@ -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(, { + 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); +}); diff --git a/src/bundled/channels/UnreadBadge.test.tsx b/src/bundled/channels/UnreadBadge.test.tsx new file mode 100644 index 00000000..eaa7a4c0 --- /dev/null +++ b/src/bundled/channels/UnreadBadge.test.tsx @@ -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( + + + , + ); + 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( + + + , + ); + 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.", + ); +}); diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index aa09520d..ec3f7c35 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -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" }; @@ -46,7 +48,10 @@ afterEach(() => { delete (HTMLElement.prototype as Partial).scrollIntoView; }); -function mount(options: Partial = {}) { +function mount( + options: Partial = {}, + providers?: readonly Contribution[], +) { let commands: ComposerToolProps; const completionRequests: ComposerCompletionProps["publish"][] = []; function Completion({ publish }: ComposerCompletionProps) { @@ -68,7 +73,7 @@ function mount(options: Partial = {}) { : null, component: Completion, }); - let completions: readonly Contribution[] = [ + let completions: readonly Contribution[] = providers ?? [ completion("1"), ]; function Tool(props: ComposerToolProps) { @@ -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(); @@ -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) => { diff --git a/tests/browser/notifications.spec.mjs b/tests/browser/notifications.spec.mjs index 323f83f8..0f623c54 100644 --- a/tests/browser/notifications.spec.mjs +++ b/tests/browser/notifications.spec.mjs @@ -136,40 +136,6 @@ test("real live traffic alerts once; replay/reload stay quiet and choices persis ).toHaveCount(0); }); -test("explicit Allow releases the first fresh alert; master off preserves categories", async ({ - page, - app, -}) => { - await ready(page, app); - await page.evaluate(() => { - window.Notification.permission = "default"; - }); - await page - .getByRole("button", { name: "Check permission", exact: true }) - .click(); - const row = liveMessage(app, "Permission wait"); - await observed(page, row.id); - expect(await systemCount(page)).toBe(0); - expect(await page.evaluate(() => window.notificationRequests)).toBe(0); - await page - .getByRole("button", { name: "Allow notifications", exact: true }) - .click(); - await expect.poll(() => systemCount(page)).toBe(1); - await page.getByRole("switch", { name: "Mentions", exact: true }).uncheck(); - await page - .getByRole("switch", { name: "Desktop alerts", exact: true }) - .uncheck(); - await page - .getByRole("switch", { name: "Desktop alerts", exact: true }) - .check(); - await expect( - page.getByRole("switch", { name: "Mentions", exact: true }), - ).not.toBeChecked(); - const muted = liveMessage(app, "Disabled category"); - await observed(page, muted.id); - expect(await systemCount(page)).toBe(1); -}); - test("a fully visible incoming row stays quiet without publishing read intent", async ({ page, app, @@ -383,32 +349,3 @@ test("an installed producer shares policy and OS click navigation, including aft await page.evaluate(() => window.fixtureNavigation.snapshot().status), ).toBe("opened"); }); - -test("asynchronous browser display failure reaches Settings once without redelivery", async ({ - page, - app, -}) => { - await ready(page, app); - liveMessage(app, "Browser display error"); - await expect.poll(() => systemCount(page)).toBe(1); - await page.evaluate(() => window.notificationEvents[0].onerror?.()); - await expect(page.getByRole("alert")).toHaveText( - "The browser could not display a notification.", - ); - expect( - await page.evaluate(() => { - const item = window.notificationEvents[0]; - return { - closed: item.closed, - click: item.onclick, - error: item.onerror, - close: item.onclose, - }; - }), - ).toEqual({ closed: true, click: null, error: null, close: null }); - await page - .getByRole("button", { name: "Check permission", exact: true }) - .click(); - await page.waitForTimeout(150); - expect(await systemCount(page)).toBe(1); -}); diff --git a/tests/browser/typeahead.spec.mjs b/tests/browser/typeahead.spec.mjs index 69ac6f4a..379e7d34 100644 --- a/tests/browser/typeahead.spec.mjs +++ b/tests/browser/typeahead.spec.mjs @@ -71,64 +71,6 @@ for (const mode of ["light", "dark"]) { } } -test("typeahead replaces only the query and publishes selected namesake identity, including replies", async ({ - page, -}) => { - const errors = []; - page.on("pageerror", (e) => errors.push(String(e))); - const input = await open(page); - const keys = await page.evaluate(() => ({ - first: window.mentionFixture.first, - second: window.mentionFixture.second, - })); - await input.fill("Before @Ho after"); - await input.evaluate((el) => { - el.setSelectionRange(10, 10); - el.dispatchEvent(new Event("select", { bubbles: true })); - }); - const option = page.getByRole("option", { - name: `Honey ${keys.second}`, - exact: true, - }); - await expect(option).toBeVisible(); - await option.click(); - await expect(input).toBeFocused(); - await expect(input).toHaveJSProperty("value", "Before @Honey after"); - await expect( - page - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button"), - ).toHaveCount(1); - await input.press("Enter"); - await expect - .poll(() => page.evaluate(() => window.mentionFixture.publications.length)) - .toBe(1); - expect( - await page.evaluate(() => - window.mentionFixture.publications[0].tags.filter(([tag]) => tag === "p"), - ), - ).toEqual([["p", keys.second]]); - await page.getByRole("button", { name: "Toggle thread" }).click(); - const reply = page.getByRole("textbox", { name: "Reply to thread" }); - await reply.fill("@Ho"); - await expect( - page.getByRole("option", { name: `Honey ${keys.first}`, exact: true }), - ).toBeVisible(); - await page - .getByRole("option", { name: `Honey ${keys.first}`, exact: true }) - .click(); - await page.evaluate(() => - window.mentionFixture.change("disable", "buzz.mentions"), - ); - await reply.press("Enter"); - await expect - .poll(() => page.evaluate(() => window.mentionFixture.publications.length)) - .toBe(2); - const sent = await page.evaluate(() => window.mentionFixture.publications[1]); - expect(sent.tags).toContainEqual(["e", "a".repeat(64), "", "reply"]); - expect(sent.tags.filter(([tag]) => tag === "p")).toEqual([["p", keys.first]]); - expect(errors).toEqual([]); -}); test("emoji keyboard, Escape, selected text, blur, IME and plugin disable preserve ordinary editing", async ({ page, }) => { @@ -291,45 +233,6 @@ test("editable composer exposes its listbox popup relationship only while sugges await expect(input).toHaveRole("textbox"); } }); -test("plugin replacement, native blur and composer sessions revoke late publications", async ({ - page, -}) => { - await page.goto("/tests/fixtures/typeahead.html"); - const input = page.getByRole("textbox", { name: "Message #Test" }); - const latest = async () => { - await expect - .poll(() => - page.evaluate(() => window.completionFixture.queries().length), - ) - .toBeGreaterThan(0); - return page.evaluate(() => window.completionFixture.queries().length - 1); - }; - const publish = (index, text = "chosen") => - page.evaluate( - ({ index, text }) => - window.completionFixture.publish(index, { - items: [{ id: text, label: text, edit: { text } }], - }), - { index, text }, - ); - await input.fill("!blur"); - const blurred = await latest(); - await page.getByRole("textbox", { name: "Message #Other" }).focus(); - expect(await publish(blurred)).toBe(false); - await input.focus(); - const removed = await latest(); - await page.evaluate(() => window.completionFixture.change("disable")); - expect(await publish(removed)).toBe(false); - await page.evaluate(() => window.completionFixture.change("enable")); - expect(await publish(removed)).toBe(false); - await input.fill("!scope"); - const scoped = await latest(); - await page.getByRole("button", { name: "Switch session" }).click(); - expect(await publish(scoped)).toBe(false); - await expect( - page.getByRole("textbox", { name: "Message #Test" }), - ).toHaveJSProperty("value", ""); -}); test("selection follows IDs through reordering and rejected replacement never falls through to send", async ({ page, }) => { @@ -792,62 +695,6 @@ test("channel and actual ThreadPanel composers keep separate completion and draf await expect(thread).toHaveJSProperty("value", "@Fixture Reader "); }); -test("native read-only state rejects a displayed choice without sending", async ({ - page, -}) => { - await page.goto("/tests/fixtures/typeahead.html"); - const input = page.getByRole("textbox", { name: "Message #Test" }); - await input.fill("!readonly"); - const current = await page.evaluate( - () => window.completionFixture.queries().length - 1, - ); - await page.evaluate( - (index) => - window.completionFixture.publish(index, { - items: [{ id: "bad", label: "Bad", edit: { text: "bad" } }], - }), - current, - ); - await expect(page.getByRole("option", { name: "Bad" })).toBeVisible(); - await input.evaluate((el) => { - el.readOnly = true; - }); - await input.press("Enter"); - await expect(input).toHaveJSProperty("value", "!readonly"); - expect( - await page.evaluate(() => window.completionFixture.publications.length), - ).toBe(0); -}); - -test("a later emoji trigger wins after a mention without discarding recipient intent", async ({ - page, -}) => { - const input = await open(page); - const key = await page.evaluate(() => window.mentionFixture.first); - await input.fill("@Ho"); - await page.getByRole("option", { name: `Honey ${key}`, exact: true }).click(); - await input.pressSequentially(":smile"); - await expect(page.getByRole("option").first()).toContainText(":smile:"); - await input.press("Tab"); - await expect(input).toHaveJSProperty("value", "@Honey 😄"); - await input.press("Enter"); - await expect - .poll(() => page.evaluate(() => window.mentionFixture.publications.length)) - .toBe(1); - expect( - await page.evaluate(() => - window.mentionFixture.publications[0].tags.filter(([tag]) => tag === "p"), - ), - ).toEqual([["p", key]]); - await input.fill("@Honey :smile"); - await expect(page.getByRole("option").first()).toContainText(":smile:"); - await input.press("Tab"); - await expect(input).toHaveJSProperty("value", "@Honey 😄"); - await expect( - page.getByRole("region", { name: "Notification recipients" }), - ).toHaveCount(0); -}); - test("portal bounds hold when the focused composer moves outside the viewport", async ({ page, }) => { diff --git a/tests/browser/unread.spec.mjs b/tests/browser/unread.spec.mjs index d87f3149..084480b6 100644 --- a/tests/browser/unread.spec.mjs +++ b/tests/browser/unread.spec.mjs @@ -222,73 +222,3 @@ test("a surviving window publishes a closed window's durable read intent", async await survivor.close(); } }); - -test.describe("explicit mark-through with membership activity", () => { - test.use({ membershipActivity: true }); - - for (const activityOnly of [false, true]) { - test( - activityOnly - ? "activity-only history explains the missing message without clearing manual unread" - : "chat followed by membership activity clears manual unread through the newest chat", - async ({ page, app }) => { - // Model only upstream signed history; the app must load and verify it. - const loaded = app.histories.get("primary/alpha").slice(-4); - app.histories.set( - "primary/alpha", - activityOnly - ? loaded.filter((event) => event.kind === 40099) - : loaded, - ); - const lastChat = loaded.findLast((event) => event.kind === 9); - await open(page, app); - await composer(page).focus(); - await expect( - history(page).locator("[data-membership-row]"), - ).toHaveCount(1); - if (!activityOnly) { - await expect(alpha(page).getByRole("img")).toHaveAttribute( - "aria-label", - /^2 observed unread messages/, - ); - } - await options(page); - await page - .getByRole("button", { - name: "Mark unread on this device", - exact: true, - }) - .click(); - await expect(alpha(page).getByRole("img")).toHaveAttribute( - "aria-label", - "Marked unread on this device only", - ); - const before = await journal(page); - await page - .getByRole("button", { - name: "Mark read through loaded messages", - exact: true, - }) - .click(); - if (activityOnly) { - await expect(page.getByRole("alert")).toHaveText( - "Load a verified message before marking through it.", - ); - const after = await journal(page); - expect(after.localUnread.alpha).toBe(before.localUnread.alpha); - expect(after.state.frontiers).toEqual(before.state.frontiers); - expect(after.revision).toBe(before.revision); - } else { - await expect - .poll(async () => (await journal(page)).state.frontiers.alpha) - .toBe(lastChat.created_at); - expect((await journal(page)).localUnread.alpha).toBeUndefined(); - await expect(alpha(page).getByRole("img")).toHaveCount(0); - await expect(page.getByRole("alert")).toHaveCount(0); - } - // Do not let the shared fixture's legacy WebKit exception mask this path. - expect(app.report.errors).toEqual([]); - }, - ); - } -});