From 80ba1f5ae060744e8a43c78dccbeb02d28ecfed7 Mon Sep 17 00:00:00 2001 From: ss-dev-00 Date: Mon, 24 Aug 2026 14:21:06 -0700 Subject: [PATCH] test(desktop): cover Escape's yield to an in-progress edit in the cover drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** test `#6575` gave `FocusThreadDrawer` a `hasActiveEdit` prop so a capture-phase Escape claim yields to an edit composer inside the drawer instead of dismissing the surface and losing the draft. Rebasing this branch moved Escape ownership out of that file and into `CoverDrawer`, so the behaviour now lives behind `escapeYieldsToContent` — and `#6575`'s only regression test for it is an e2e case written against the thread drawer, which cannot see the agent drawer or the prop boundary itself. This adds jsdom coverage at the new seam: a press inside the drawer yields while content owns the key, closes when it does not, and a press from outside closes it either way, so the yield cannot wedge the drawer open. Verified to fail on the first case with the guard removed, while both negative controls keep passing. Signed-off-by: ss-dev-00 Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen --- .../channels/ui/CoverDrawerEscape.test.mjs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 desktop/src/features/channels/ui/CoverDrawerEscape.test.mjs diff --git a/desktop/src/features/channels/ui/CoverDrawerEscape.test.mjs b/desktop/src/features/channels/ui/CoverDrawerEscape.test.mjs new file mode 100644 index 0000000000..5f8132f2cb --- /dev/null +++ b/desktop/src/features/channels/ui/CoverDrawerEscape.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + pretendToBeVisual: true, + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + KeyboardEvent: dom.window.KeyboardEvent, + MutationObserver: dom.window.MutationObserver, + Node: dom.window.Node, + cancelAnimationFrame: dom.window.cancelAnimationFrame, + document: dom.window.document, + requestAnimationFrame: dom.window.requestAnimationFrame, + window: dom.window, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia ??= () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +/** + * Renders a drawer holding one focusable child, which stands in for the thread + * composer that owns Escape while an edit is in progress. + */ +async function renderDrawer({ escapeYieldsToContent }) { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { CoverDrawer } = await import("./CoverDrawer.tsx"); + + const closes = []; + const view = render( + React.createElement( + CoverDrawer, + { + ariaLabel: "Thread", + escapeYieldsToContent, + onClose: () => closes.push("close"), + scrimLabel: "Back to #general", + testId: "cover-drawer", + }, + React.createElement("input", { "data-testid": "thread-composer" }), + ), + ); + + return { closes, view }; +} + +function pressEscapeOn(element) { + element.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + }), + ); +} + +function composer() { + return dom.window.document.querySelector('[data-testid="thread-composer"]'); +} + +test("Escape inside the drawer yields to content while it owns the key", async () => { + // The regression this guards (#6575): the drawer claims Escape in the capture + // phase, which runs before the composer's own handler. Without the yield, one + // press dismisses the entire drawer instead of cancelling the in-progress + // edit, and the unsaved draft goes with it. + const { closes } = await renderDrawer({ escapeYieldsToContent: true }); + + pressEscapeOn(composer()); + + assert.deepEqual(closes, []); +}); + +test("Escape inside the drawer closes it when content does not own the key", async () => { + // The default: with no active edit the same press is a dismissal, so the yield + // above must be conditional rather than a blanket exemption for the subtree. + const { closes } = await renderDrawer({ escapeYieldsToContent: false }); + + pressEscapeOn(composer()); + + assert.deepEqual(closes, ["close"]); +}); + +test("Escape from outside the drawer closes it even while content owns the key", async () => { + // The yield is scoped to the drawer's own subtree, so an active edit inside + // cannot wedge the drawer open against a press from the channel behind it. + const { closes } = await renderDrawer({ escapeYieldsToContent: true }); + + pressEscapeOn(dom.window.document.body); + + assert.deepEqual(closes, ["close"]); +});