| 1 | // Run: tsx src/__tests__/composer-context-menu-clipboard.test.tsx |
| 2 | // |
| 3 | // Regression coverage for the composer edit context menu's clipboard guards: |
| 4 | // - Undo/redo must expose the same fixed shortcuts as the composer keyboard path. |
| 5 | // - Cut must not delete the selection when every clipboard path failed. |
| 6 | // - Paste must not replace the selection when the clipboard has no text. |
| 7 | // - Shortcut hints must use the platform modifier (Ctrl outside macOS). |
| 8 | |
| 9 | import { JSDOM } from "jsdom"; |
| 10 | import React from "react"; |
| 11 | import { act } from "react"; |
| 12 | import { createRoot } from "react-dom/client"; |
| 13 | import { Composer } from "../components/Composer"; |
| 14 | import { LocaleProvider } from "../lib/i18n"; |
| 15 | import { ToastProvider } from "../lib/toast"; |
| 16 | import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types"; |
| 17 | |
| 18 | let passed = 0; |
| 19 | let failed = 0; |
| 20 | |
| 21 | function ok(value: boolean, label: string) { |
| 22 | if (value) { |
| 23 | process.stdout.write(` PASS ${label}\n`); |
| 24 | passed += 1; |
| 25 | } else { |
| 26 | process.stdout.write(` FAIL ${label}\n`); |
| 27 | failed += 1; |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | function eq(actual: unknown, expected: unknown, label: string) { |
| 32 | if (actual === expected) ok(true, label); |
| 33 | else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); |
| 34 | } |
| 35 | |
| 36 | function flushTimers(): Promise<void> { |
| 37 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 38 | } |
| 39 | |
| 40 | class TestResizeObserver { |
| 41 | observe() {} |
| 42 | unobserve() {} |
| 43 | disconnect() {} |
| 44 | } |
| 45 | |
| 46 | function installDom() { |
| 47 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 48 | pretendToBeVisual: true, |
| 49 | url: "http://localhost/", |
| 50 | }); |
| 51 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 52 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 53 | globalThis.document = dom.window.document; |
| 54 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 55 | globalThis.Node = dom.window.Node; |
| 56 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 57 | globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement; |
| 58 | globalThis.Event = dom.window.Event; |
| 59 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 60 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 61 | globalThis.InputEvent = dom.window.InputEvent; |
| 62 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 63 | globalThis.File = dom.window.File; |
| 64 | globalThis.FileReader = dom.window.FileReader; |
| 65 | globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent; |
| 66 | globalThis.MutationObserver = dom.window.MutationObserver; |
| 67 | globalThis.localStorage = dom.window.localStorage; |
| 68 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 69 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 70 | globalThis.ResizeObserver = TestResizeObserver; |
| 71 | Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} }); |
| 72 | Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} }); |
| 73 | Object.defineProperty(window, "matchMedia", { |
| 74 | configurable: true, |
| 75 | value: () => ({ |
| 76 | matches: true, |
| 77 | media: "(prefers-reduced-motion: reduce)", |
| 78 | onchange: null, |
| 79 | addEventListener() {}, |
| 80 | removeEventListener() {}, |
| 81 | addListener() {}, |
| 82 | removeListener() {}, |
| 83 | dispatchEvent: () => false, |
| 84 | }), |
| 85 | }); |
| 86 | return dom; |
| 87 | } |
| 88 | |
| 89 | function installBridgeApp(methods: Record<string, unknown>) { |
| 90 | (window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = { |
| 91 | go: undefined, |
| 92 | main: { |
| 93 | App: { |
| 94 | Commands: async () => [], |
| 95 | Models: async () => [], |
| 96 | ModelsForTab: async () => [], |
| 97 | ...methods, |
| 98 | }, |
| 99 | }, |
| 100 | } as never; |
| 101 | } |
| 102 | |
| 103 | async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) { |
| 104 | const rootEl = document.getElementById("root"); |
| 105 | if (!rootEl) throw new Error("missing root"); |
| 106 | const root = createRoot(rootEl); |
| 107 | const currentProps: Parameters<typeof Composer>[0] = { |
| 108 | running: false, |
| 109 | collaborationMode: "normal", |
| 110 | toolApprovalMode: "ask" as ToolApprovalMode, |
| 111 | tokenMode: "full" as TokenMode, |
| 112 | goal: "", |
| 113 | cwd: "/repo", |
| 114 | modelLabel: "DeepSeek-R1", |
| 115 | imageInputEnabled: true, |
| 116 | tabId: "context-menu-tab", |
| 117 | sessionKey: "session:project:/repo:topic-a:session-a", |
| 118 | onSend: () => {}, |
| 119 | onCancel: () => undefined, |
| 120 | onCycleMode: () => {}, |
| 121 | onSetMode: () => {}, |
| 122 | onSetCollaborationMode: (_mode: CollaborationMode) => {}, |
| 123 | onSetToolApprovalMode: () => {}, |
| 124 | onToggleYoloApprovalMode: () => {}, |
| 125 | onClearGoal: () => {}, |
| 126 | onSwitchModel: () => {}, |
| 127 | onSetEffort: () => {}, |
| 128 | onSetTokenMode: () => {}, |
| 129 | ready: true, |
| 130 | ...props, |
| 131 | }; |
| 132 | await act(async () => { |
| 133 | root.render( |
| 134 | <LocaleProvider> |
| 135 | <ToastProvider> |
| 136 | <div className="chat-pane"> |
| 137 | <Composer {...currentProps} /> |
| 138 | </div> |
| 139 | </ToastProvider> |
| 140 | </LocaleProvider>, |
| 141 | ); |
| 142 | await flushTimers(); |
| 143 | }); |
| 144 | return { root }; |
| 145 | } |
| 146 | |
| 147 | function textarea(): HTMLTextAreaElement { |
| 148 | const node = document.querySelector("textarea") as HTMLTextAreaElement | null; |
| 149 | if (!node) throw new Error("composer textarea did not render"); |
| 150 | return node; |
| 151 | } |
| 152 | |
| 153 | // textPasteEvent seeds composer text through its own paste handler — the same |
| 154 | // pattern composer-session-draft.test.tsx uses — because the controlled |
| 155 | // textarea's state is not reachable through synthetic input events here. |
| 156 | function textPasteEvent(text: string): Event { |
| 157 | const event = new window.Event("paste", { bubbles: true, cancelable: true }); |
| 158 | Object.defineProperty(event, "clipboardData", { |
| 159 | configurable: true, |
| 160 | value: { |
| 161 | files: [], |
| 162 | items: [], |
| 163 | types: ["text/plain"], |
| 164 | getData: (kind: string) => (kind === "text" || kind === "text/plain" ? text : ""), |
| 165 | }, |
| 166 | }); |
| 167 | return event; |
| 168 | } |
| 169 | |
| 170 | async function typeAndSelect(value: string, from: number, to: number) { |
| 171 | const node = textarea(); |
| 172 | await act(async () => { |
| 173 | node.focus(); |
| 174 | node.setSelectionRange(0, node.value.length); |
| 175 | node.dispatchEvent(textPasteEvent(value)); |
| 176 | await flushTimers(); |
| 177 | }); |
| 178 | if (textarea().value !== value) throw new Error(`composer text = ${JSON.stringify(textarea().value)}, want ${JSON.stringify(value)}`); |
| 179 | // Drain the paste handler's deferred focusInputRange (rAF + timers) before |
| 180 | // pinning the selection under test, or it would reset the caret afterwards. |
| 181 | await act(async () => { |
| 182 | await new Promise((resolve) => window.requestAnimationFrame(() => resolve(null))); |
| 183 | await flushTimers(); |
| 184 | }); |
| 185 | textarea().focus(); |
| 186 | textarea().setSelectionRange(from, to); |
| 187 | } |
| 188 | |
| 189 | async function openInputMenu(): Promise<HTMLButtonElement[]> { |
| 190 | await act(async () => { |
| 191 | textarea().dispatchEvent( |
| 192 | new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }), |
| 193 | ); |
| 194 | await flushTimers(); |
| 195 | }); |
| 196 | const items = Array.from(document.querySelectorAll(".context-menu__item")) as HTMLButtonElement[]; |
| 197 | if (items.length !== 6) throw new Error(`expected 6 edit menu items, got ${items.length}`); |
| 198 | return items; |
| 199 | } |
| 200 | |
| 201 | async function clickMenuItem(item: HTMLButtonElement) { |
| 202 | await act(async () => { |
| 203 | item.dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true })); |
| 204 | await flushTimers(); |
| 205 | await flushTimers(); |
| 206 | }); |
| 207 | } |
| 208 | |
| 209 | function stubClipboard(overrides: Partial<{ writeText: unknown; readText: unknown; read: unknown }>) { |
| 210 | Object.defineProperty(window.navigator, "clipboard", { |
| 211 | configurable: true, |
| 212 | value: { |
| 213 | writeText: () => Promise.reject(new Error("clipboard denied")), |
| 214 | readText: () => Promise.resolve(""), |
| 215 | read: () => Promise.reject(new Error("clipboard.read unsupported")), |
| 216 | ...overrides, |
| 217 | }, |
| 218 | }); |
| 219 | } |
| 220 | |
| 221 | async function main() { |
| 222 | installDom(); |
| 223 | installBridgeApp({ |
| 224 | // The empty-paste path probes the native clipboard for an image; a reject |
| 225 | // must stay silent (notifyOnError=false) and never touch the draft text. |
| 226 | SaveClipboardImage: async () => { |
| 227 | throw new Error("no native clipboard image"); |
| 228 | }, |
| 229 | }); |
| 230 | |
| 231 | // JSDOM reports a non-mac platform, so the menu must advertise Ctrl, not ⌘. |
| 232 | await renderComposer(); |
| 233 | |
| 234 | // --- shortcut hints use the platform modifier --- |
| 235 | { |
| 236 | await typeAndSelect("hello world", 0, 5); |
| 237 | const items = await openInputMenu(); |
| 238 | const hints = items.map((item) => item.querySelector(".context-menu__shortcut")?.textContent ?? ""); |
| 239 | eq(hints[0], "Ctrl+Z", "undo hint uses the platform modifier"); |
| 240 | eq(hints[1], "Ctrl+Shift+Z", "redo hint uses the platform modifier"); |
| 241 | eq(hints[2], "Ctrl+X", "cut hint uses the platform modifier"); |
| 242 | eq(hints[4], "Ctrl+V", "paste hint uses the platform modifier"); |
| 243 | ok(hints.every((hint) => !hint.includes("⌘")), "no hardcoded mac glyph on a non-mac platform"); |
| 244 | // Close the menu without acting. |
| 245 | await act(async () => { |
| 246 | document.body.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true })); |
| 247 | await flushTimers(); |
| 248 | }); |
| 249 | } |
| 250 | |
| 251 | // --- context-menu undo and redo share the programmatic edit history --- |
| 252 | { |
| 253 | await typeAndSelect("menu undo", 9, 9); |
| 254 | const undoItems = await openInputMenu(); |
| 255 | ok(!undoItems[0].disabled, "undo is enabled at a programmatic edit boundary"); |
| 256 | ok(undoItems[1].disabled, "redo is disabled before an undo"); |
| 257 | await clickMenuItem(undoItems[0]); |
| 258 | eq(textarea().value, "hello world", "context-menu undo restores the previous composer text"); |
| 259 | |
| 260 | const redoItems = await openInputMenu(); |
| 261 | ok(redoItems[0].disabled === false, "older programmatic history remains undoable"); |
| 262 | ok(!redoItems[1].disabled, "redo is enabled after context-menu undo"); |
| 263 | await clickMenuItem(redoItems[1]); |
| 264 | eq(textarea().value, "menu undo", "context-menu redo restores the undone composer edit"); |
| 265 | } |
| 266 | |
| 267 | // --- cut keeps the draft when every clipboard path fails --- |
| 268 | { |
| 269 | stubClipboard({}); |
| 270 | (document as Document & { execCommand?: () => boolean }).execCommand = () => false; |
| 271 | await typeAndSelect("hello world", 0, 5); |
| 272 | const items = await openInputMenu(); |
| 273 | await clickMenuItem(items[2]); |
| 274 | eq(textarea().value, "hello world", "failed cut must not delete the selection"); |
| 275 | } |
| 276 | |
| 277 | // --- cut removes the selection once a clipboard path succeeds --- |
| 278 | { |
| 279 | stubClipboard({ writeText: () => Promise.resolve() }); |
| 280 | await typeAndSelect("hello world", 0, 6); |
| 281 | const items = await openInputMenu(); |
| 282 | await clickMenuItem(items[2]); |
| 283 | eq(textarea().value, "world", "successful cut removes the selected text"); |
| 284 | } |
| 285 | |
| 286 | // --- paste with an empty clipboard keeps the selection --- |
| 287 | { |
| 288 | stubClipboard({ readText: () => Promise.resolve("") }); |
| 289 | await typeAndSelect("hello world", 0, 5); |
| 290 | const items = await openInputMenu(); |
| 291 | await clickMenuItem(items[4]); |
| 292 | eq(textarea().value, "hello world", "empty-clipboard paste must not erase the selection"); |
| 293 | } |
| 294 | |
| 295 | // --- paste with text replaces the selection --- |
| 296 | { |
| 297 | stubClipboard({ readText: () => Promise.resolve("bye") }); |
| 298 | await typeAndSelect("hello world", 0, 5); |
| 299 | const items = await openInputMenu(); |
| 300 | await clickMenuItem(items[4]); |
| 301 | eq(textarea().value, "bye world", "text paste replaces the selection"); |
| 302 | } |
| 303 | |
| 304 | process.stdout.write(`\n${passed} passed, ${failed} failed, ${passed + failed} total\n`); |
| 305 | if (failed > 0) process.exit(1); |
| 306 | } |
| 307 | |
| 308 | void main(); |
| 309 |