Skip to content
Merged
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
149 changes: 146 additions & 3 deletions src/features/messages/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<HTMLElement>).scrollIntoView;
});
afterEach(cleanup);

function mount(options: Partial<MessageComposerProps> = {}) {
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<ComposerCompletion> => ({
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<ComposerCompletion>[] = [
completion("1"),
];
function Tool(props: ComposerToolProps) {
useLayoutEffect(() => {
commands = props;
Expand Down Expand Up @@ -115,6 +153,13 @@ function mount(options: Partial<MessageComposerProps> = {}) {
extensions: {
tools: { snapshot: () => tools, subscribe: () => () => {} },
inline: { snapshot: () => inline, subscribe: () => () => {} },
completions: {
snapshot: () => completions,
subscribe(listener) {
completionListeners.add(listener);
return () => completionListeners.delete(listener);
},
},
},
...options,
};
Expand All @@ -132,6 +177,25 @@ function mount(options: Partial<MessageComposerProps> = {}) {
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<ComposerCompletionProps["publish"]> = false;
act(() => {
published = request(result);
});
return published;
},
replaceCompletionProvider() {
act(() => {
completions = [completion("2")];
for (const listener of completionListeners) listener();
});
},
retarget(next: Partial<MessageComposerProps>) {
props = { ...props, ...next };
view.rerender(<MessageComposer {...props} />);
Expand All @@ -143,15 +207,94 @@ function mount(options: Partial<MessageComposerProps> = {}) {
});
},
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"));
},
};
}

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");
Expand Down
34 changes: 2 additions & 32 deletions tests/browser/typeahead.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
Loading