diff --git a/desktop/src/features/channels/freshMentionChatJourney.test.mjs b/desktop/src/features/channels/freshMentionChatJourney.test.mjs index 9a1335e109..b63a1ed222 100644 --- a/desktop/src/features/channels/freshMentionChatJourney.test.mjs +++ b/desktop/src/features/channels/freshMentionChatJourney.test.mjs @@ -239,9 +239,12 @@ function Composer() { { onKeyDown: (event) => { // Only editor events reach this bridge, as in MessageComposer. - if (event.key === "Tab" && event.shiftKey && - event.target === richText.editor.view.dom && - focusMentionOptionsTrigger(formRef.current)) { + if ( + event.key === "Tab" && + event.shiftKey && + event.target === richText.editor.view.dom && + focusMentionOptionsTrigger(formRef.current) + ) { event.preventDefault(); return; } @@ -258,7 +261,9 @@ function Composer() { React.createElement(MentionAutocomplete, { composerOwnsFocus: true, keepMentionedAgentsPinned: keepPinned, - onKeepMentionedAgentsPinnedChange: state.withOptions ? setKeepPinned : undefined, + onKeepMentionedAgentsPinnedChange: state.withOptions + ? setKeepPinned + : undefined, isOpen: mention.isMentionOpen, isLoading: mention.isMentionLoading, suggestions: mention.suggestions, @@ -495,19 +500,30 @@ for (const mode of ["Enter", "pin"]) { test(`navigation: editor ShiftTab abandons ${mode} even after return`, async () => { await setup({ owner: OTHER, visible: true, directoryVisible: true }); let resolve; - state.fresh = new Promise((r) => { resolve = r; }); + state.fresh = new Promise((r) => { + resolve = r; + }); await act(async () => { richText.editor.view.dom.focus(); choose(mode); }); unchanged(); await act(async () => { - richText.editor.view.dom.dispatchEvent(new dom.window.KeyboardEvent("keydown", { - key: "Tab", shiftKey: true, bubbles: true, cancelable: true, - })); + richText.editor.view.dom.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); const trigger = document.querySelector("[data-mention-options-trigger]"); assert.ok(trigger, "chat Options trigger exists"); - assert.equal(document.activeElement === trigger, true, "ShiftTab focuses Options"); + assert.equal( + document.activeElement === trigger, + true, + "ShiftTab focuses Options", + ); richText.editor.view.dom.focus(); }); await act(async () => resolve([rawAgent()])); @@ -521,65 +537,110 @@ for (const shiftKey of [false, true]) { test(`navigation: focused pin ${shiftKey ? "ShiftTab" : "Tab"} depart=${depart}`, async () => { await setup({ owner: OTHER, visible: true, directoryVisible: true }); let resolve; - state.fresh = new Promise((r) => { resolve = r; }); - const toggle = document.querySelector(`[data-testid="mention-always-address-${AGENT}"]`); + state.fresh = new Promise((r) => { + resolve = r; + }); + const toggle = document.querySelector( + `[data-testid="mention-always-address-${AGENT}"]`, + ); assert.ok(toggle, "production pin Toggle exists"); await act(async () => { toggle.focus(); - assert.equal(document.activeElement === toggle, true, "keyboard pin owns focus"); + assert.equal( + document.activeElement === toggle, + true, + "keyboard pin owns focus", + ); const key = shiftKey ? " " : "Enter"; const event = new dom.window.KeyboardEvent("keydown", { - key, bubbles: true, cancelable: true, + key, + bubbles: true, + cancelable: true, }); toggle.dispatchEvent(event); - assert.equal(event.defaultPrevented, false, "overlay activation stays native"); - assert.equal(state.freshCalls ?? 0, 0, "pin keydown must not select in editor"); + assert.equal( + event.defaultPrevented, + false, + "overlay activation stays native", + ); + assert.equal( + state.freshCalls ?? 0, + 0, + "pin keydown must not select in editor", + ); const release = new dom.window.KeyboardEvent("keyup", { - key, bubbles: true, cancelable: true, + key, + bubbles: true, + cancelable: true, }); // JSDOM lacks native activation: Enter clicks on keydown, Space on keyup. if (key === " ") toggle.dispatchEvent(release); - if (!event.defaultPrevented && !release.defaultPrevented) toggle.click(); + if (!event.defaultPrevented && !release.defaultPrevented) + toggle.click(); }); unchanged(); assert.equal(state.freshCalls, 1); - if (depart) await act(async () => { - const event = new dom.window.KeyboardEvent("keydown", { - key: "Tab", shiftKey, bubbles: true, cancelable: true, + if (depart) + await act(async () => { + const event = new dom.window.KeyboardEvent("keydown", { + key: "Tab", + shiftKey, + bubbles: true, + cancelable: true, + }); + toggle.dispatchEvent(event); + assert.equal( + event.defaultPrevented, + false, + "overlay Tab stays native", + ); + const outside = document.createElement("button"); + document.body.append(outside); + if (!event.defaultPrevented) outside.focus(); + assert.equal( + document.activeElement === outside, + true, + "Tab departs pin", + ); + toggle.focus(); }); - toggle.dispatchEvent(event); - assert.equal(event.defaultPrevented, false, "overlay Tab stays native"); - const outside = document.createElement("button"); - document.body.append(outside); - if (!event.defaultPrevented) outside.focus(); - assert.equal(document.activeElement === outside, true, "Tab departs pin"); - toggle.focus(); - }); await act(async () => resolve([rawAgent()])); await settle(); if (depart) { unchanged(); assert.equal(picker.announcement, ""); } else { - assert.deepEqual(effects.filter(([kind]) => kind === "promote"), [["promote", AGENT]]); - assert.deepEqual(effects.filter(([kind]) => kind === "autoPin"), []); - assert.deepEqual(effects.filter(([kind]) => kind === "provenance"), [ - ["provenance", [{ pubkey: AGENT, prefix: "@Remote Scout " }]], - ]); + assert.deepEqual( + effects.filter(([kind]) => kind === "promote"), + [["promote", AGENT]], + ); + assert.deepEqual( + effects.filter(([kind]) => kind === "autoPin"), + [], + ); + assert.deepEqual( + effects.filter(([kind]) => kind === "provenance"), + [["provenance", [{ pubkey: AGENT, prefix: "@Remote Scout " }]]], + ); assert.equal(effects.filter(([kind]) => kind === "edit").length, 2); assert.deepEqual(getMentionSelectionHistory(VIEWER, CHANNEL), []); assert.equal(richText.getPlainTextAndCursor().text, "@Remote Scout "); - assert.equal(mention.getDraftMentionRefs("@Remote Scout ")[0]?.pubkey, AGENT); + assert.equal( + mention.getDraftMentionRefs("@Remote Scout ")[0]?.pubkey, + AGENT, + ); // MentionHighlightExtension decorates literal @labels from either path; // .mention-chip is not selection provenance. The pin witnesses above // distinguish admission paths; verify the prefix is plain document text // (useRichTextEditor.replacePlainTextRange), not an embedded mention node. assert.deepEqual(richText.editor.getJSON(), { type: "doc", - content: [{ - type: "paragraph", - content: [{ type: "text", text: "@Remote Scout " }], - }], + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "@Remote Scout " }], + }, + ], }); } }); @@ -588,10 +649,16 @@ for (const shiftKey of [false, true]) { for (const departure of ["window", "no-Options native fallback"]) { test(`navigation: ${departure} abandons pending selection after return`, async () => { - await setup({ owner: OTHER, visible: true, directoryVisible: true, - withOptions: departure !== "no-Options native fallback" }); + await setup({ + owner: OTHER, + visible: true, + directoryVisible: true, + withOptions: departure !== "no-Options native fallback", + }); let resolve; - state.fresh = new Promise((r) => { resolve = r; }); + state.fresh = new Promise((r) => { + resolve = r; + }); await act(async () => { richText.editor.view.dom.focus(); choose("Enter"); @@ -603,20 +670,38 @@ for (const departure of ["window", "no-Options native fallback"]) { if (departure === "window") { // Window departure can retain activeElement; dispatch only that boundary. dom.window.dispatchEvent(new dom.window.Event("blur")); - assert.equal(document.activeElement === editor, true, "window blur retains editor identity"); + assert.equal( + document.activeElement === editor, + true, + "window blur retains editor identity", + ); dom.window.dispatchEvent(new dom.window.Event("focus")); } else { - assert.equal(document.querySelector("[data-mention-options-trigger]") === null, true); + assert.equal( + document.querySelector("[data-mention-options-trigger]") === null, + true, + ); const event = new dom.window.KeyboardEvent("keydown", { - key: "Tab", shiftKey: true, bubbles: true, cancelable: true, + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, }); editor.dispatchEvent(event); - assert.equal(event.defaultPrevented, false, "no Options leaves ShiftTab native"); + assert.equal( + event.defaultPrevented, + false, + "no Options leaves ShiftTab native", + ); // Explicit native Tab default emulation, not a browser tab-order claim. const outside = document.createElement("button"); document.body.append(outside); if (!event.defaultPrevented) outside.focus(); - assert.equal(document.activeElement === outside, true, "native fallback departs editor"); + assert.equal( + document.activeElement === outside, + true, + "native fallback departs editor", + ); editor.focus(); } }); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index c2d72d34c1..1a4385cc4e 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -2343,9 +2343,7 @@ test("navigating to mention Options during a held selection inserts nothing late await expect(input).toBeFocused(); await page.keyboard.press("Shift+Tab"); await expect( - page - .getByTestId("message-composer") - .getByTestId("mention-options-trigger"), + page.getByTestId("message-composer").getByTestId("mention-options-trigger"), ).toBeFocused(); // Release the held authority response and settle the downstream DOM. @@ -2437,9 +2435,9 @@ test("navigating away from the mention row pin during its held revalidation pins await holdMentionGateCommand(page, "revalidate_relay_agents"); await page.keyboard.press("Enter"); await waitForMentionGate(page); - await expect( - page.getByTestId("composer-address-lock-status"), - ).toContainText("Checking access"); + await expect(page.getByTestId("composer-address-lock-status")).toContainText( + "Checking access", + ); // Native Shift+Tab from the pin — the editor's key handler sees no overlay // events — must really move focus off the pin control. @@ -2510,120 +2508,136 @@ test("navigating away from the mention row pin during its held revalidation pins `composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, ), ).toBeVisible(); - await expect( - page.getByTestId("composer-address-lock-status"), - ).toContainText("Automatically mentioning quinn"); + await expect(page.getByTestId("composer-address-lock-status")).toContainText( + "Automatically mentioning quinn", + ); await expect(input).toHaveText("@quinn "); await expect(input.locator(".agent-mention-highlight")).toHaveText("quinn"); }); -test("editing and restoring the draft during a held mention selection inserts nothing late", async ({ - page, -}) => { - await installMockBridge(page, { - relayAgents: [ - { - pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, - name: "quinn", - respondTo: "allowlist", - respondToAllowlist: [MOCK_VIEWER_PUBKEY], - channelNames: ["general"], - }, - ], - }); - await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); - - const composer = page.getByTestId("message-composer"); - const input = page.getByTestId("message-input"); - // Read-only DOM offsets: no editor state or browser selection is changed. - const readCaret = () => - input.evaluate((element) => { - const selection = window.getSelection(); - if ( - !selection?.anchorNode || - !selection.focusNode || - !element.contains(selection.anchorNode) || - !element.contains(selection.focusNode) - ) { - return null; - } - const offset = (node: Node, position: number) => { - const range = document.createRange(); - range.selectNodeContents(element); - range.setEnd(node, position); - return range.toString().length; - }; - return { - anchor: offset(selection.anchorNode, selection.anchorOffset), - focus: offset(selection.focusNode, selection.focusOffset), - collapsed: selection.isCollapsed, - }; +for (const change of ["draft", "selection range"] as const) { + test(`editing and restoring the ${change} during a held mention selection inserts nothing late`, async ({ + page, + }) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], }); - await input.fill("@quinn"); - const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); - await expect(quinnRow).toBeVisible(); - await expect(quinnRow).toBeEnabled(); - await expect(input).toBeFocused(); - const originalCaret = await readCaret(); - expect(originalCaret).toEqual({ anchor: 6, focus: 6, collapsed: true }); - const baselineCommands = await readCommandLog(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); - await holdMentionGateCommand(page, "revalidate_relay_agents"); - await quinnRow.click(); - await waitForMentionGate(page); - const status = page.getByTestId("composer-address-lock-status"); - await expect(status).toContainText("Checking access"); - await expect(input).toBeFocused(); - await expect.poll(readCaret).toEqual(originalCaret); + const composer = page.getByTestId("message-composer"); + const input = page.getByTestId("message-input"); + // Read-only DOM offsets: no editor state or browser selection is changed. + const readCaret = () => + input.evaluate((element) => { + const selection = window.getSelection(); + if ( + !selection?.anchorNode || + !selection.focusNode || + !element.contains(selection.anchorNode) || + !element.contains(selection.focusNode) + ) { + return null; + } + const offset = (node: Node, position: number) => { + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(node, position); + return range.toString().length; + }; + return { + anchor: offset(selection.anchorNode, selection.anchorOffset), + focus: offset(selection.focusNode, selection.focusOffset), + collapsed: selection.isCollapsed, + }; + }); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await expect(quinnRow).toBeEnabled(); + await expect(input).toBeFocused(); + const originalCaret = await readCaret(); + expect(originalCaret).toEqual({ anchor: 6, focus: 6, collapsed: true }); + const baselineCommands = await readCommandLog(page); - // Real edits invalidate the pending operation even when text AND caret - // return to their original values before the allowed response arrives. - await page.keyboard.press("Backspace"); - await expect(input).toHaveText("@quin"); - await expect(input).toBeFocused(); - await page.keyboard.type("n"); - await expect(input).toHaveText("@quinn"); - await expect(input).toBeFocused(); - await expect.poll(readCaret).toEqual(originalCaret); + await holdMentionGateCommand(page, "revalidate_relay_agents"); + await quinnRow.click(); + await waitForMentionGate(page); + const status = page.getByTestId("composer-address-lock-status"); + await expect(status).toContainText("Checking access"); + await expect(input).toBeFocused(); + await expect.poll(readCaret).toEqual(originalCaret); - await releaseMentionGate(page); - await expect - .poll(async () => - commandCount(await readCommandLog(page), "revalidate_relay_agents"), - ) - .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); - // Same downstream settlement window as the adjacent held-gate probes. - await page.waitForTimeout(300); + if (change === "draft") { + // Behavior coverage: text edits also invalidate the query revision. + await page.keyboard.press("Backspace"); + await expect(input).toHaveText("@quin"); + await expect(input).toBeFocused(); + await page.keyboard.type("n"); + } else { + // Query snapshots use the selection ANCHOR, not its moving head. + // Extend and retract a real native range without changing text/anchor: + // query revision cannot mask removal of editor cancellation listeners. + await page.keyboard.press("Shift+ArrowLeft"); + await expect(input).toHaveText("@quinn"); + await expect(input).toBeFocused(); + await expect.poll(readCaret).toEqual({ + anchor: 6, + focus: 5, + collapsed: false, + }); + await page.keyboard.press("Shift+ArrowRight"); + } + await expect(input).toHaveText("@quinn"); + await expect(input).toBeFocused(); + await expect.poll(readCaret).toEqual(originalCaret); - await expect(input).toBeFocused(); - await expect(input).toHaveText("@quinn"); - await expect.poll(readCaret).toEqual(originalCaret); - await expect(input.locator(".mention-chip")).toHaveCount(0); - await expect(input.locator(".agent-mention-highlight")).toHaveCount(0); - await expect( - composer.getByTestId( - `composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, - ), - ).toHaveCount(0); - await expect(status).not.toContainText("Checking access"); - await expect(status).not.toContainText("Automatically mentioning"); - const settledCommands = await readCommandLog(page); - for (const command of [ - "add_channel_members", - "start_managed_agent", - "attach_managed_agent", - "sync_agents_to_active_huddle", - "send_channel_message", - "sign_event", - ]) { - expect(commandCount(settledCommands, command)).toBe( - commandCount(baselineCommands, command), - ); - } - expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); -}); + await releaseMentionGate(page); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + // Same downstream settlement window as the adjacent held-gate probes. + await page.waitForTimeout(300); + + await expect(input).toBeFocused(); + await expect(input).toHaveText("@quinn"); + await expect.poll(readCaret).toEqual(originalCaret); + await expect(input.locator(".mention-chip")).toHaveCount(0); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(0); + await expect( + composer.getByTestId( + `composer-address-lock-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, + ), + ).toHaveCount(0); + await expect(status).not.toContainText("Checking access"); + await expect(status).not.toContainText("Automatically mentioning"); + const settledCommands = await readCommandLog(page); + for (const command of [ + "add_channel_members", + "start_managed_agent", + "attach_managed_agent", + "sync_agents_to_active_huddle", + "send_channel_message", + "sign_event", + ]) { + expect(commandCount(settledCommands, command)).toBe( + commandCount(baselineCommands, command), + ); + } + expect(await readOutgoingMentionPubkeys(page, "@quinn")).toBeNull(); + }); +} test("selected relay agents are invited as bots before sending", async ({ page,