Skip to content

Commit 8aa995e

Browse files
authored
feat(🎄🎁): support reading the OSC52 clipboard contents in tests (#862)
**Issue:** An important part of terminal applications is the ability to set and read the system clipboard. This is not possible to test. **Solution:** Support an in-memory OSC52 clipboard in the terminal testing session that can be tested. OSC52 is a terminal escape sequence that allows terminal applications to set the clipboard contents of the host terminal. It works locally and even over ssh connections. Features: - store the latest clipboard contents in memory, and allow **read-only** access in tests. - tests can only read the clipboard contents, not set them. - the test application (e.g. neovim) can read + write the clipboard contents normally using OSC52 requests. - Does not affect the system clipboard of the host system (your computer), which could disturb other tasks while the tests are running. Now they are completely separate. - support both "system" and "primary" clipboards. This is what xtermjs exposes, and we now support both. The "system" clipboard seems to be the default on macOS, while "primary" is probably used on Linux X11. References: - xtermjs 6.0 added support for OSC52 clipboard requests via xtermjs/xterm.js#4220 - https://neovim.io/doc/user/provider.html#clipboard-osc52 Closes #857
1 parent bfb6f9f commit 8aa995e

File tree

12 files changed

+211
-15
lines changed

12 files changed

+211
-15
lines changed

packages/integration-tests/cypress/e2e/neovim.cy.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,42 @@ describe("nvim_isRunning", () => {
406406
})
407407
})
408408

409+
it("can read the OSC52 clipboard contents", () => {
410+
// OSC52 allows terminal applications to set the system clipboard by sending
411+
// an escape sequence. Neovim supports this natively since version 0.10.
412+
//
413+
// References:
414+
// - https://github.com/neovim/neovim/pull/25872
415+
// - :help clipboard-osc52 (https://neovim.io/doc/user/provider.html#clipboard-osc52)
416+
cy.visit("/")
417+
cy.startNeovim().then(nvim => {
418+
cy.contains("If you see this text, Neovim is ready!")
419+
420+
// Verify clipboard starts empty
421+
nvim.clipboard.system().should("eql", "")
422+
423+
// Configure Neovim to use OSC52 for clipboard
424+
//
425+
// See `:help clipboard-osc52` in neovim for more information
426+
nvim.runLuaCode({ luaCode: `vim.g.clipboard = 'osc52'` })
427+
428+
// Yank some text to the clipboard using Neovim
429+
// First, select the word "see" and yank it to the + register
430+
cy.typeIntoTerminal("/see{enter}")
431+
cy.typeIntoTerminal('viw"+y')
432+
433+
// Verify the clipboard now contains "see"
434+
nvim.clipboard.system().should("eql", "see")
435+
436+
// verify that the clipboard contents can be read
437+
cy.typeIntoTerminal(`"_dd`)
438+
cy.typeIntoTerminal("p")
439+
nvim.waitForLuaCode({
440+
luaAssertion: `assert(vim.api.nvim_buf_get_lines(0, 0, -1, false)[1] == 'see')`,
441+
})
442+
})
443+
})
444+
409445
// test some types and make sure they are as expected
410446

411447
// oxlint-disable-next-line no-constant-condition

packages/integration-tests/cypress/e2e/terminal.cy.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,29 @@ describe("TerminalTestApplication features", () => {
135135
})
136136
})
137137

138+
it("can support OSC52 clipboard requests", () => {
139+
// OSC52 is used this way: the terminal application sends an escape
140+
// sequence that requests the host terminal (e.g. iTerm2, kitty, wezterm,
141+
// ghostty) to set the clipboard contents to some data. This should work
142+
// even through an ssh connection to an external system, so it's very
143+
// flexible.
144+
//
145+
// Some references:
146+
// - https://github.com/xtermjs/xterm.js/pull/4220
147+
// - https://github.com/neovim/neovim/pull/25872
148+
cy.visit("/")
149+
cy.startTerminalApplication({
150+
commandToRun: ["bash"],
151+
}).then(term => {
152+
cy.contains("myprompt")
153+
term.clipboard.system().should("eql", "")
154+
155+
// send an OSC52 clipboard set request to the terminal
156+
cy.typeIntoTerminal(`printf "\\033]52;c;$(printf "%s" "blabla" | base64)\\a"{enter}`)
157+
term.clipboard.system().should("eql", "blabla")
158+
})
159+
})
160+
138161
describe("mise integration", () => {
139162
it("can use applications installed to the host environment with the miseIntegration", () => {
140163
cy.visit("/")

packages/integration-tests/cypress/support/tui-sandbox.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export type TerminalTestApplicationContext = {
3636

3737
/** The test directory, providing type-safe access to its file and directory structure */
3838
dir: TestDirectory<MyTestDirectory>
39+
40+
/** Access to the clipboard of the terminal */
41+
clipboard: {
42+
system(): Cypress.Chainable<string>
43+
primary(): Cypress.Chainable<string>
44+
}
3945
}
4046

4147
/** The api that can be used in tests after a Neovim instance has been started. */
@@ -76,6 +82,12 @@ export type NeovimContext = {
7682

7783
/** The test directory, providing type-safe access to its file and directory structure */
7884
dir: TestDirectory<MyTestDirectory>
85+
86+
/** Access to the clipboard of the terminal */
87+
clipboard: {
88+
system(): Cypress.Chainable<string>
89+
primary(): Cypress.Chainable<string>
90+
}
7991
}
8092

8193
/** Arguments for starting the neovim server. They are built based on your test
@@ -127,6 +139,15 @@ Cypress.Commands.add("startNeovim", (startArguments?: MyStartNeovimServerArgumen
127139
cy.typeIntoTerminal(text, options)
128140
},
129141
dir: underlyingNeovim.dir as TestDirectory<MyTestDirectory>,
142+
143+
clipboard: {
144+
primary() {
145+
return cy.then(() => underlyingNeovim.clipboard.primary())
146+
},
147+
system() {
148+
return cy.then(() => underlyingNeovim.clipboard.system())
149+
},
150+
},
130151
}
131152

132153
return api
@@ -163,6 +184,14 @@ Cypress.Commands.add("startTerminalApplication", (args: StartTerminalGenericArgu
163184
typeIntoTerminal(text, options) {
164185
cy.typeIntoTerminal(text, options)
165186
},
187+
clipboard: {
188+
primary() {
189+
return cy.then(() => terminal.clipboard.primary())
190+
},
191+
system() {
192+
return cy.then(() => terminal.clipboard.system())
193+
},
194+
},
166195
}
167196

168197
return api

packages/library/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@catppuccin/palette": "1.7.1",
4747
"@trpc/client": "11.8.1",
4848
"@trpc/server": "11.8.1",
49+
"@xterm/addon-clipboard": "0.2.0",
4950
"@xterm/addon-fit": "0.10.0",
5051
"@xterm/addon-unicode11": "0.8.0",
5152
"@xterm/xterm": "5.5.0",

packages/library/src/browser/neovim-client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Terminal } from "@xterm/xterm"
22
import "@xterm/xterm/css/xterm.css"
3+
import type { InMemoryClipboard } from "../client/clipboard.js"
34
import { NeovimTerminalClient } from "../client/neovim-terminal-client.js"
45
import type { TuiTerminalApi } from "../client/startTerminal.js"
56
import "../client/style.css"
@@ -38,6 +39,7 @@ export type GenericNeovimBrowserApi = {
3839
waitForLuaCode(input: PollLuaCodeClientInput): Promise<RunLuaCodeOutput>
3940
runExCommand(input: ExCommandClientInput): Promise<RunExCommandOutput>
4041
dir: TestDirectory
42+
clipboard: InMemoryClipboard
4143
}
4244

4345
/** Entrypoint for the test runner (cypress) */
@@ -68,6 +70,7 @@ window.startNeovim = async function (startArgs?: StartNeovimGenericArguments): P
6870
return neovim.runExCommand(input)
6971
},
7072
dir: testDirectory,
73+
clipboard: neovim.clipboard,
7174
}
7275

7376
return neovimBrowserApi
@@ -83,6 +86,7 @@ declare global {
8386
export type GenericTerminalBrowserApi = {
8487
dir: TestDirectory
8588
runBlockingShellCommand(input: BlockingCommandClientInput): Promise<BlockingShellCommandOutput>
89+
clipboard: InMemoryClipboard
8690
}
8791

8892
export type BrowserTerminalSettings = {
@@ -123,6 +127,7 @@ window.startTerminalApplication = async function (
123127
runBlockingShellCommand(input) {
124128
return terminal.runBlockingShellCommand(input)
125129
},
130+
clipboard: terminal.clipboard,
126131
}
127132
return terminalBrowserApi
128133
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { ClipboardSelectionType, IClipboardProvider } from "@xterm/addon-clipboard"
2+
3+
export type InMemoryClipboard = {
4+
/**
5+
* Get the system clipboard contents. This seems to be the default on osx. If
6+
* you know what other systems use this by default, please open an issue or
7+
* PR to enhance this documentation!
8+
*/
9+
system(): string
10+
11+
/** Get the primary clipboard contents. */
12+
primary(): string
13+
}
14+
15+
export class InMemoryClipboardProvider implements IClipboardProvider, InMemoryClipboard {
16+
private clipboardContents: Record<ClipboardSelectionType, string> = {
17+
c: "", // SYSTEM
18+
p: "", // PRIMARY
19+
}
20+
21+
public system(): string {
22+
return this.clipboardContents.c
23+
}
24+
25+
public primary(): string {
26+
return this.clipboardContents.p
27+
}
28+
29+
async readText(selection: ClipboardSelectionType): Promise<string> {
30+
return this.clipboardContents[selection]
31+
}
32+
33+
async writeText(selection: ClipboardSelectionType, text: string): Promise<void> {
34+
this.clipboardContents[selection] = text
35+
}
36+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// This is the public client api. Semantic versioning will be applied to this.
22

3+
export type { InMemoryClipboard } from "./clipboard.js"
34
export { rgbify } from "./color-utilities.js"
45
export { textIsVisibleWithBackgroundColor, textIsVisibleWithColor } from "./cypress-assertions.js"
56
export type { MyNeovimConfigModification } from "./MyNeovimConfigModification.ts"

packages/library/src/client/neovim-terminal-client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import type {
1515
StartNeovimGenericArguments,
1616
TestDirectory,
1717
} from "../server/types.js"
18+
import type { InMemoryClipboard } from "./clipboard.js"
19+
import { InMemoryClipboardProvider } from "./clipboard.js"
1820
import { getTabId, startTerminal } from "./startTerminal.js"
1921

2022
/** Manages the terminal state in the browser as well as the (browser's)
@@ -24,6 +26,7 @@ export class NeovimTerminalClient {
2426
private readonly tabId: { tabId: string }
2527
private readonly terminal: Terminal
2628
private readonly trpc: ReturnType<typeof createTRPCClient<AppRouter>>
29+
public readonly clipboard: InMemoryClipboard
2730

2831
constructor(app: HTMLElement) {
2932
const trpc = createTRPCClient<AppRouter>({
@@ -44,6 +47,7 @@ export class NeovimTerminalClient {
4447
this.tabId = getTabId()
4548
const tabId = this.tabId
4649

50+
const clipboard = new InMemoryClipboardProvider()
4751
const terminal = startTerminal(app, {
4852
onMouseEvent(data: string) {
4953
void trpc.neovim.sendStdin.mutate({ tabId, data }).catch((error: unknown) => {
@@ -53,7 +57,9 @@ export class NeovimTerminalClient {
5357
onKeyPress(event) {
5458
void trpc.neovim.sendStdin.mutate({ tabId, data: event.key })
5559
},
60+
clipboard,
5661
})
62+
this.clipboard = clipboard
5763
this.terminal = terminal
5864

5965
// start listening to Neovim stdout - this will take some (short) amount of

packages/library/src/client/startTerminal.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { flavors } from "@catppuccin/palette"
2+
import { ClipboardAddon } from "@xterm/addon-clipboard"
23
import { FitAddon } from "@xterm/addon-fit"
34
import { Unicode11Addon } from "@xterm/addon-unicode11"
45
import { Terminal } from "@xterm/xterm"
56
import * as z from "zod"
67
import type { TabId } from "../server/utilities/tabId.ts"
8+
import type { InMemoryClipboardProvider } from "./clipboard.js"
79
import { validateMouseEvent } from "./validateMouseEvent.js"
810

911
export type TuiTerminalApi = {
1012
onMouseEvent: (data: string) => void
1113
onKeyPress: (event: { key: string; domEvent: KeyboardEvent }) => void
14+
clipboard: InMemoryClipboardProvider
1215
}
1316
export function startTerminal(app: HTMLElement, api: TuiTerminalApi): Terminal {
1417
const terminal = new Terminal({
@@ -38,23 +41,28 @@ export function startTerminal(app: HTMLElement, api: TuiTerminalApi): Terminal {
3841
yellow: colors.yellow.hex,
3942
}
4043

41-
// The FitAddon makes the terminal fit the size of the container, the entire
42-
// page in this case
43-
const fitAddon = new FitAddon()
44-
terminal.loadAddon(fitAddon)
44+
{
45+
// The FitAddon makes the terminal fit the size of the container, the entire
46+
// page in this case
47+
const fitAddon = new FitAddon()
48+
terminal.loadAddon(fitAddon)
4549

46-
// The Unicode11Addon fixes emoji rendering issues. Without it, emoji are
47-
// displayed as truncated (partial) images.
48-
const unicode11Addon = new Unicode11Addon()
49-
terminal.loadAddon(unicode11Addon)
50-
terminal.unicode.activeVersion = "11"
50+
// The Unicode11Addon fixes emoji rendering issues. Without it, emoji are
51+
// displayed as truncated (partial) images.
52+
const unicode11Addon = new Unicode11Addon()
53+
terminal.loadAddon(unicode11Addon)
54+
terminal.unicode.activeVersion = "11"
5155

52-
terminal.open(app)
53-
fitAddon.fit()
54-
55-
window.addEventListener("resize", () => {
56+
terminal.open(app)
5657
fitAddon.fit()
57-
})
58+
59+
window.addEventListener("resize", () => {
60+
fitAddon.fit()
61+
})
62+
63+
const clipboardAddon = new ClipboardAddon(undefined, api.clipboard)
64+
terminal.loadAddon(clipboardAddon)
65+
}
5866

5967
terminal.onData(data => {
6068
data satisfies string

packages/library/src/client/terminal-terminal-client.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { StartTerminalBrowserArguments } from "../browser/neovim-client.js"
44
import type { BlockingCommandClientInput } from "../server/blockingCommandInputSchema.js"
55
import type { AppRouter } from "../server/server.js"
66
import type { BlockingShellCommandOutput, ServerTestDirectory } from "../server/types.js"
7+
import type { InMemoryClipboard } from "./clipboard.js"
8+
import { InMemoryClipboardProvider } from "./clipboard.js"
79
import type { TuiTerminalApi } from "./startTerminal.js"
810
import { getTabId, startTerminal } from "./startTerminal.js"
911
import { supportDA1 } from "./terminal-config.js"
@@ -15,7 +17,9 @@ export class TerminalTerminalClient {
1517
private readonly tabId: { tabId: string }
1618
private readonly terminal: Terminal
1719
private readonly trpc: ReturnType<typeof createTRPCClient<AppRouter>>
18-
terminalApi: TuiTerminalApi
20+
21+
public readonly clipboard: InMemoryClipboard
22+
public readonly terminalApi: TuiTerminalApi
1923

2024
constructor(app: HTMLElement) {
2125
const trpc = createTRPCClient<AppRouter>({
@@ -36,6 +40,7 @@ export class TerminalTerminalClient {
3640
this.tabId = getTabId()
3741
const tabId = this.tabId
3842

43+
const clipboard = new InMemoryClipboardProvider()
3944
this.terminalApi = {
4045
onMouseEvent(data: string) {
4146
void trpc.terminal.sendStdin.mutate({ tabId, data }).catch((error: unknown) => {
@@ -45,7 +50,9 @@ export class TerminalTerminalClient {
4550
onKeyPress(event) {
4651
void trpc.terminal.sendStdin.mutate({ tabId, data: event.key })
4752
},
53+
clipboard,
4854
}
55+
this.clipboard = clipboard
4956
const terminal = startTerminal(app, this.terminalApi)
5057
this.terminal = terminal
5158

0 commit comments

Comments
 (0)