Skip to content

Commit e555130

Browse files
committed
test(terminal): add selection property invariants
1 parent 4f5a867 commit e555130

5 files changed

Lines changed: 433 additions & 219 deletions

packages/terminal/src/web/terminal-copy-native-menu.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import type { TerminalCopyMouseEvent } from "./terminal-copy-selection-drag.js"
22

3+
/**
4+
* Facade for xterm's hidden textarea used by native browser copy commands.
5+
*
6+
* @pure true - the type only describes the injected DOM shell boundary.
7+
* @effect none at declaration time; implementations perform focus/select/style/value DOM effects.
8+
* @invariant Style and value writes affect the same textarea that focus() and select() address.
9+
* @precondition Implementations are xterm-compatible textarea-like objects.
10+
* @postcondition Consumers can prepare selected text for the browser context-menu Copy command.
11+
* @complexity O(1)
12+
* @throws Never
13+
*/
314
export type TerminalCopyTextarea = {
415
readonly focus: () => void
516
readonly select: () => void
@@ -13,13 +24,35 @@ export type TerminalCopyTextarea = {
1324
value: string
1425
}
1526

27+
/**
28+
* Minimal host facade for locating the terminal screen relative to context-menu coordinates.
29+
*
30+
* @pure true - the type only describes optional DOM lookup capabilities.
31+
* @effect none at declaration time; implementations may perform DOM layout reads when called.
32+
* @invariant querySelector(".xterm-screen") returns an element in the host coordinate space when available.
33+
* @precondition Host methods, when present, follow DOM getBoundingClientRect/querySelector contracts.
34+
* @postcondition Consumers can resolve the screen origin without depending on concrete browser classes.
35+
* @complexity O(1)
36+
* @throws Never
37+
*/
1638
type TerminalCopyScreenElement = {
1739
readonly getBoundingClientRect: () => {
1840
readonly left: number
1941
readonly top: number
2042
}
2143
}
2244

45+
/**
46+
* Host facade used by the native copy menu preparation shell.
47+
*
48+
* @pure true - the type only describes injected DOM capabilities.
49+
* @effect none at declaration time; method implementations can read DOM layout.
50+
* @invariant At least one of host.getBoundingClientRect or host.querySelector(".xterm-screen") may define origin.
51+
* @precondition Optional methods preserve their `this` binding semantics when called through this facade.
52+
* @postcondition Consumers can choose the most precise terminal screen origin available.
53+
* @complexity O(1)
54+
* @throws Never
55+
*/
2356
export type TerminalNativeCopyMenuHost = {
2457
readonly getBoundingClientRect?: TerminalCopyScreenElement["getBoundingClientRect"]
2558
readonly querySelector?: (selector: string) => TerminalCopyScreenElement | null
@@ -36,8 +69,54 @@ const terminalContextMenuTextareaOffsetPx = 10
3669
const terminalContextMenuTextareaSizePx = 20
3770
const xtermScreenSelector = ".xterm-screen"
3871

72+
/**
73+
* Normalizes optional event coordinates to the DOM origin default.
74+
*
75+
* @param value - Optional mouse coordinate from a browser-like event facade.
76+
* @returns The coordinate value or zero when the event omits it.
77+
* @pure true
78+
* @effect none
79+
* @invariant result = value when value is defined; otherwise result = 0.
80+
* @precondition value is either undefined or a finite event coordinate.
81+
* @postcondition The caller can use the result in textarea positioning arithmetic.
82+
* @complexity O(1)
83+
* @throws Never
84+
*/
85+
// CHANGE: normalize optional mouse coordinates for native copy textarea positioning
86+
// WHY: synthetic test events and browser events can omit client coordinates
87+
// QUOTE(ТЗ): n/a
88+
// REF: PR-407-CodeRabbit-native-copy-menu-docs
89+
// SOURCE: n/a
90+
// FORMAT THEOREM: optionalNumber(x) = x if x is defined, otherwise 0
91+
// PURITY: CORE
92+
// EFFECT: none
93+
// INVARIANT: result is always a number
94+
// COMPLEXITY: O(1)/O(1)
3995
const optionalNumber = (value: number | undefined): number => value ?? 0
4096

97+
/**
98+
* Adapts a host-level bounding box method into a screen element facade.
99+
*
100+
* @param host - Native copy menu host with an optional getBoundingClientRect method.
101+
* @returns A screen element facade when the host can provide its own rectangle; otherwise null.
102+
* @pure true - does not read layout until the returned facade is invoked.
103+
* @effect none during resolution; returned facade calls host.getBoundingClientRect().
104+
* @invariant Non-null result preserves host as the `this` value for getBoundingClientRect.
105+
* @precondition host is the DOM-like object that owns the optional getBoundingClientRect method.
106+
* @postcondition The caller can use the result as a fallback screen-origin provider.
107+
* @complexity O(1)
108+
* @throws Never
109+
*/
110+
// CHANGE: preserve host-bound layout reads when no .xterm-screen child is available
111+
// WHY: the fallback must call getBoundingClientRect with the original DOM receiver
112+
// QUOTE(ТЗ): n/a
113+
// REF: PR-407-CodeRabbit-native-copy-menu-docs
114+
// SOURCE: n/a
115+
// FORMAT THEOREM: hasRect(host) => result.getBoundingClientRect() = host.getBoundingClientRect.call(host)
116+
// PURITY: SHELL
117+
// EFFECT: deferred host.getBoundingClientRect()
118+
// INVARIANT: null is returned iff the host has no bounding-rect method
119+
// COMPLEXITY: O(1)/O(1)
41120
const resolveContextMenuHostScreenElement = (
42121
host: TerminalNativeCopyMenuHost
43122
): TerminalCopyScreenElement | null => {
@@ -50,11 +129,57 @@ const resolveContextMenuHostScreenElement = (
50129
}
51130
}
52131

132+
/**
133+
* Resolves the preferred terminal screen origin for native copy menu positioning.
134+
*
135+
* @param host - Native copy menu host that may expose xterm screen lookup or host bounds.
136+
* @returns The xterm screen element when available, otherwise host bounds, otherwise null.
137+
* @pure true - delegates to injected DOM facades without mutating them.
138+
* @effect optional host.querySelector(".xterm-screen") and deferred getBoundingClientRect reads.
139+
* @invariant querySelector(".xterm-screen") takes precedence over host-level bounds.
140+
* @precondition host methods follow DOM-compatible contracts.
141+
* @postcondition Non-null result can provide coordinates for the helper textarea.
142+
* @complexity O(1)
143+
* @throws Never
144+
*/
145+
// CHANGE: prefer the xterm screen coordinate space for context-menu helper positioning
146+
// WHY: textarea coordinates need to be relative to the terminal screen, not an arbitrary ancestor
147+
// QUOTE(ТЗ): n/a
148+
// REF: PR-407-CodeRabbit-native-copy-menu-docs
149+
// SOURCE: n/a
150+
// FORMAT THEOREM: screen(host) = querySelector(.xterm-screen) ?? hostBounds(host)
151+
// PURITY: SHELL
152+
// EFFECT: optional host.querySelector lookup
153+
// INVARIANT: screen child lookup wins over host fallback
154+
// COMPLEXITY: O(1)/O(1)
53155
const resolveContextMenuScreenElement = (
54156
host: TerminalNativeCopyMenuHost
55157
): TerminalCopyScreenElement | null =>
56158
host.querySelector?.(xtermScreenSelector) ?? resolveContextMenuHostScreenElement(host)
57159

160+
/**
161+
* Prepares xterm's hidden textarea so the native browser context-menu Copy item copies terminal text.
162+
*
163+
* @param args - Event coordinates, host lookup facade, cached selection text, and xterm textarea facade.
164+
* @returns True iff a non-empty selection was written into a resolved textarea and selected for copying.
165+
* @pure false - mutates the injected textarea and reads DOM layout through injected facades.
166+
* @effect textarea.style/value writes, textarea.focus(), textarea.select(), screen.getBoundingClientRect().
167+
* @invariant result => selection.length > 0 and textarea.value = selection.
168+
* @precondition event coordinates are in the same viewport space as getBoundingClientRect values.
169+
* @postcondition Success positions a small helper textarea near the context-menu event and selects its value.
170+
* @complexity O(n) where n = selection.length.
171+
* @throws Never under the injected facade contracts.
172+
*/
173+
// CHANGE: prepare xterm's hidden textarea before the browser context menu opens
174+
// WHY: Chrome only shows and executes native Copy when a focused selected textarea value exists
175+
// QUOTE(ТЗ): "А куда пропала кнопка copy?"
176+
// REF: user-message-2026-06-15-native-copy-menu
177+
// SOURCE: n/a
178+
// FORMAT THEOREM: selection != "" and textarea and screen => prepared(textarea, selection)
179+
// PURITY: SHELL
180+
// EFFECT: DOM textarea style/value/focus/select and layout reads
181+
// INVARIANT: false result leaves textarea unmodified by this function
182+
// COMPLEXITY: O(n)/O(1)
58183
export const prepareNativeBrowserCopyMenu = (
59184
{ event, host, selection, textarea }: PrepareNativeBrowserCopyMenuArgs
60185
): boolean => {
@@ -74,6 +199,28 @@ export const prepareNativeBrowserCopyMenu = (
74199
return true
75200
}
76201

202+
/**
203+
* Clears the helper textarea after the copy context is consumed or invalidated.
204+
*
205+
* @param textarea - Optional xterm helper textarea facade.
206+
* @pure false - mutates the injected textarea when present.
207+
* @effect textarea.value = "".
208+
* @invariant textarea === undefined => no effect; textarea !== undefined => textarea.value = "" after return.
209+
* @precondition textarea, when present, accepts value writes.
210+
* @postcondition No stale cached selection remains in the helper textarea.
211+
* @complexity O(1)
212+
* @throws Never under the injected facade contract.
213+
*/
214+
// CHANGE: clear native copy helper text when selection context is no longer active
215+
// WHY: stale helper textarea contents must not be copied after selection invalidation
216+
// QUOTE(ТЗ): n/a
217+
// REF: PR-407-CodeRabbit-native-copy-menu-docs
218+
// SOURCE: n/a
219+
// FORMAT THEOREM: clear(textarea) => textarea.value = "" when textarea exists
220+
// PURITY: SHELL
221+
// EFFECT: textarea.value mutation
222+
// INVARIANT: undefined textarea is a no-op
223+
// COMPLEXITY: O(1)/O(1)
77224
export const clearNativeBrowserCopyMenu = (
78225
textarea: TerminalCopyTextarea | undefined
79226
): void => {

packages/terminal/tests/web/terminal-copy-interaction.test.ts

Lines changed: 40 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { describe, expect, it } from "@effect/vitest"
2+
import { Effect } from "effect"
3+
import * as fc from "fast-check"
24

35
import {
46
attachTerminalCopyInteraction,
@@ -29,6 +31,8 @@ const terminalWithSelection = (
2931
modes: { mouseTrackingMode }
3032
})
3133

34+
const activeMouseTrackingModeArbitrary = fc.constantFrom<TerminalMouseTrackingMode>("any", "drag", "vt200", "x10")
35+
3236
const keyboardEvent = (
3337
key: string,
3438
options: Partial<Pick<TerminalCopyKeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "type">> = {}
@@ -238,47 +242,42 @@ describe("terminal copy interaction", () => {
238242
disposable.dispose()
239243
})
240244

241-
it("suppresses protected context menus without blocking the native menu", () => {
242-
const host = new FakeTerminalCopyHost(null)
243-
const contextMenuReports: Array<TerminalCopyTestMouseEvent> = []
244-
host.addBubbleMouseListener("contextmenu", (event) => {
245-
contextMenuReports.push(event)
246-
})
247-
const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("any", "selected") })
248-
const down = mouseEvent(2)
249-
const contextMenu = mouseEvent(2, "contextmenu")
250-
251-
host.dispatchMouse("mousedown", down)
252-
host.dispatchMouse("contextmenu", contextMenu)
253-
254-
expect(contextMenu.shiftKey).toBe(true)
255-
expect(contextMenu.preventDefaultCalls).toBe(0)
256-
expect(contextMenu.stopImmediatePropagationCalls).toBe(1)
257-
expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1)
258-
expect(contextMenuReports).toEqual([])
259-
260-
disposable.dispose()
261-
})
262-
263-
it("suppresses protected context menus even when the browser reports primary button", () => {
264-
const host = new FakeTerminalCopyHost(null)
265-
const contextMenuReports: Array<TerminalCopyTestMouseEvent> = []
266-
host.addBubbleMouseListener("contextmenu", (event) => {
267-
contextMenuReports.push(event)
268-
})
269-
const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("any", "selected") })
270-
const contextMenu = mouseEvent(0, "contextmenu")
271-
272-
host.dispatchMouse("contextmenu", contextMenu)
273-
274-
expect(contextMenu.shiftKey).toBe(true)
275-
expect(contextMenu.preventDefaultCalls).toBe(0)
276-
expect(contextMenu.stopImmediatePropagationCalls).toBe(1)
277-
expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1)
278-
expect(contextMenuReports).toEqual([])
279-
280-
disposable.dispose()
281-
})
245+
it.effect("suppresses generated protected context menus without blocking the native menu", () =>
246+
Effect.sync(() => {
247+
fc.assert(
248+
fc.property(
249+
activeMouseTrackingModeArbitrary,
250+
fc.string({ maxLength: 64, minLength: 1 }),
251+
fc.constantFrom(0, 2),
252+
(mouseTrackingMode, selectedText, contextMenuButton) => {
253+
const host = new FakeTerminalCopyHost(null)
254+
const contextMenuReports: Array<TerminalCopyTestMouseEvent> = []
255+
host.addBubbleMouseListener("contextmenu", (event) => {
256+
contextMenuReports.push(event)
257+
})
258+
const disposable = attachTerminalCopyInteraction({
259+
host,
260+
terminal: terminalWithSelection(mouseTrackingMode, selectedText)
261+
})
262+
const contextMenu = mouseEvent(contextMenuButton, "contextmenu")
263+
264+
if (contextMenuButton === 2) {
265+
host.dispatchMouse("mousedown", mouseEvent(2))
266+
}
267+
host.dispatchMouse("contextmenu", contextMenu)
268+
269+
expect(contextMenu.shiftKey).toBe(true)
270+
expect(contextMenu.preventDefaultCalls).toBe(0)
271+
expect(contextMenu.stopImmediatePropagationCalls).toBe(1)
272+
expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1)
273+
expect(contextMenuReports).toEqual([])
274+
275+
disposable.dispose()
276+
}
277+
),
278+
{ numRuns: 100 }
279+
)
280+
}))
282281

283282
it("does not start a forced selection drag when mouse tracking is inactive", () => {
284283
const documentTarget = new FakeTerminalCopyEventTarget()

0 commit comments

Comments
 (0)