diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 95e23608..aa09520d 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -13,8 +13,11 @@ import userEvent from "@testing-library/user-event"; import { useLayoutEffect } from "react"; import type { Contribution } from "../../plugins/contributions"; import type { + ComposerCompletion, + ComposerCompletionProps, ComposerTool, ComposerToolProps, + CompletionResult, InlineRenderer, } from "../conversation/contracts"; import { MessageComposer, type MessageComposerProps } from "./MessageComposer"; @@ -28,11 +31,46 @@ const second = { pubkey: "b".repeat(64), name: "Honey" }; beforeEach(() => { localStorage.clear(); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + HTMLElement.prototype.scrollIntoView = vi.fn(); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + delete (HTMLElement.prototype as Partial).scrollIntoView; }); -afterEach(cleanup); function mount(options: Partial = {}) { let commands: ComposerToolProps; + const completionRequests: ComposerCompletionProps["publish"][] = []; + function Completion({ publish }: ComposerCompletionProps) { + useLayoutEffect(() => { + completionRequests.push(publish); + }, [publish]); + return null; + } + const completionListeners = new Set<() => void>(); + const completion = (revision: string): Contribution => ({ + id: "delayed", + key: "test/delayed", + pluginId: "test", + revision, + title: "Delayed", + match: ({ text, start }) => + text.startsWith("!") && start > 0 + ? { start: 0, end: start, query: text.slice(1, start) } + : null, + component: Completion, + }); + let completions: readonly Contribution[] = [ + completion("1"), + ]; function Tool(props: ComposerToolProps) { useLayoutEffect(() => { commands = props; @@ -115,6 +153,13 @@ function mount(options: Partial = {}) { extensions: { tools: { snapshot: () => tools, subscribe: () => () => {} }, inline: { snapshot: () => inline, subscribe: () => () => {} }, + completions: { + snapshot: () => completions, + subscribe(listener) { + completionListeners.add(listener); + return () => completionListeners.delete(listener); + }, + }, }, ...options, }; @@ -132,6 +177,25 @@ function mount(options: Partial = {}) { emojiListeners, user: userEvent.setup(), commands: () => commands, + completionRequests, + publish(index: number, text = "chosen") { + const request = completionRequests[index]; + if (!request) throw new Error("No observed completion request"); + const result: CompletionResult = { + items: [{ id: text, label: text, edit: { text } }], + }; + let published: ReturnType = false; + act(() => { + published = request(result); + }); + return published; + }, + replaceCompletionProvider() { + act(() => { + completions = [completion("2")]; + for (const listener of completionListeners) listener(); + }); + }, retarget(next: Partial) { props = { ...props, ...next }; view.rerender(); @@ -143,8 +207,11 @@ function mount(options: Partial = {}) { }); }, fill(text: string) { - fireEvent.input(input(), { target: { value: text } }); - input().setSelectionRange(text.length, text.length); + const field = input(); + act(() => field.focus()); + field.value = text; + field.setSelectionRange(text.length, text.length); + fireEvent.input(field); }, submit() { fireEvent.submit(within(view.container).getByRole("form")); @@ -152,6 +219,82 @@ function mount(options: Partial = {}) { }; } +it("revokes stale completion publications across editor and ownership lifecycles and recovers freshly", () => { + const h = mount(); + const input = h.input(); + input.focus(); + h.fill("!a"); + const edit = h.completionRequests.length - 1; + h.fill("!b"); + h.fill("!a"); + expect(h.publish(edit, "stale ABA")).toBe(false); + const afterAba = h.completionRequests.length - 1; + expect(h.publish(afterAba)).not.toBe(false); + expect(screen.getByRole("option", { name: "chosen" })).toBeVisible(); + + fireEvent.keyDown(input, { key: "Escape" }); + expect(h.publish(afterAba, "stale dismissal")).toBe(false); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + + h.fill("!provider"); + const oldProvider = h.completionRequests.length - 1; + h.replaceCompletionProvider(); + expect(h.publish(oldProvider, "stale provider")).toBe(false); + const replacement = h.completionRequests.length - 1; + expect(h.publish(replacement, "replacement fresh")).not.toBe(false); + expect( + screen.getByRole("option", { name: "replacement fresh" }), + ).toBeVisible(); + + h.fill("!destination"); + const oldDestination = h.completionRequests.length - 1; + h.retarget({ threadRootId: "root" }); + expect(h.input()).toHaveValue(""); + expect(h.publish(oldDestination, "stale destination")).toBe(false); + h.input().focus(); + h.fill("!fresh"); + const fresh = h.completionRequests.length - 1; + expect(h.publish(fresh, "fresh recovery")).not.toBe(false); + expect(screen.getByRole("option", { name: "fresh recovery" })).toBeVisible(); + + h.unmount(); + expect(h.publish(fresh, "stale unmount")).toBe(false); +}); + +it.each(["disabled", "readOnly"] as const)( + "rejects late and displayed completion results when the editor becomes %s", + (state) => { + const h = mount(); + const input = h.input(); + input.focus(); + h.fill("!late"); + const late = h.completionRequests.length - 1; + if (state === "disabled") { + h.retarget({ disabled: true }); + expect(input).toHaveAttribute("aria-disabled", "true"); + expect(input).toHaveAttribute("contenteditable", "false"); + } else input.readOnly = true; + expect(h.publish(late, "late result")).toBe(false); + + if (state === "disabled") h.retarget({ disabled: false }); + else input.readOnly = false; + expect(h.input().disabled).toBe(false); + expect(h.input().readOnly).toBe(false); + h.input().focus(); + h.fill("!displayed"); + const displayed = h.completionRequests.length - 1; + expect(h.publish(displayed, "displayed choice")).not.toBe(false); + expect( + screen.getByRole("option", { name: "displayed choice" }), + ).toBeVisible(); + if (state === "disabled") h.retarget({ disabled: true }); + else h.input().readOnly = true; + fireEvent.keyDown(h.input(), { key: "Enter" }); + expect(h.input()).toHaveValue("!displayed"); + expect(h.messages.send).not.toHaveBeenCalled(); + }, +); + it("sends channel messages and thread replies through real form and keyboard events", async () => { const h = mount(); await h.user.type(h.input(), "channel draft"); diff --git a/tests/browser/typeahead.spec.mjs b/tests/browser/typeahead.spec.mjs index 8d71d882..171edeee 100644 --- a/tests/browser/typeahead.spec.mjs +++ b/tests/browser/typeahead.spec.mjs @@ -224,7 +224,7 @@ test("editable composer exposes its listbox popup relationship only while sugges await expect(input).toHaveRole("textbox"); } }); -test("late publications cannot cross edits, ABA, Escape, blur, plugin replacement or destinations", async ({ +test("plugin replacement, native blur and composer sessions revoke late publications", async ({ page, }) => { await page.goto("/tests/fixtures/typeahead.html"); @@ -245,20 +245,6 @@ test("late publications cannot cross edits, ABA, Escape, blur, plugin replacemen }), { index, text }, ); - await input.fill("!a"); - const old = await latest(); - await input.fill("!b"); - await input.fill("!a"); - await expect.poll(latest).toBeGreaterThan(old); - expect(await publish(old, "STALE ABA")).toBe(false); - const current = await latest(); - expect(await publish(current)).toBe(true); - await expect( - page.getByRole("option", { name: "chosen", exact: true }), - ).toBeVisible(); - await input.press("Escape"); - expect(await publish(current, "STALE ESCAPE")).toBe(false); - await expect(page.getByRole("listbox")).toHaveCount(0); await input.fill("!blur"); const blurred = await latest(); await page.getByRole("textbox", { name: "Message #Other" }).focus(); @@ -739,27 +725,11 @@ test("channel and actual ThreadPanel composers keep separate completion and draf await expect(thread).toHaveJSProperty("value", "@Fixture Reader "); }); -test("disabled and read-only DOM state reject late publications and displayed choices", async ({ +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("!disabled"); - const old = await page.evaluate( - () => window.completionFixture.queries().length - 1, - ); - await page.getByRole("button", { name: "Toggle disabled" }).click(); - await expect(input).toBeDisabled(); - expect( - await page.evaluate( - (index) => - window.completionFixture.publish(index, { - items: [{ id: "bad", label: "Bad", edit: { text: "bad" } }], - }), - old, - ), - ).toBe(false); - await page.getByRole("button", { name: "Toggle disabled" }).click(); await input.fill("!readonly"); const current = await page.evaluate( () => window.completionFixture.queries().length - 1,