|
8 | 8 | showClipboardMessage, |
9 | 9 | subscribeClipboardMessages, |
10 | 10 | clearClipboardMessage, |
| 11 | + registerClipboardRenderer, |
| 12 | + unregisterClipboardRenderer, |
11 | 13 | } from '../clipboard' |
12 | 14 | import { logger } from '../logger' |
13 | 15 |
|
@@ -399,6 +401,139 @@ describe('clipboard', () => { |
399 | 401 | }) |
400 | 402 | }) |
401 | 403 |
|
| 404 | + describe('registerClipboardRenderer and renderer-based copy', () => { |
| 405 | + let originalPlatform: PropertyDescriptor | undefined |
| 406 | + let originalEnv: Record<string, string | undefined> |
| 407 | + let loggerErrorSpy: ReturnType<typeof spyOn> |
| 408 | + |
| 409 | + beforeEach(() => { |
| 410 | + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') |
| 411 | + originalEnv = { |
| 412 | + SSH_CLIENT: process.env.SSH_CLIENT, |
| 413 | + SSH_TTY: process.env.SSH_TTY, |
| 414 | + SSH_CONNECTION: process.env.SSH_CONNECTION, |
| 415 | + TERM: process.env.TERM, |
| 416 | + TMUX: process.env.TMUX, |
| 417 | + STY: process.env.STY, |
| 418 | + } |
| 419 | + loggerErrorSpy = spyOn(logger, 'error').mockImplementation(() => {}) |
| 420 | + |
| 421 | + // Use freebsd + dumb terminal to disable platform tools and OSC52, |
| 422 | + // isolating the renderer path. |
| 423 | + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) |
| 424 | + delete process.env.SSH_CLIENT |
| 425 | + delete process.env.SSH_TTY |
| 426 | + delete process.env.SSH_CONNECTION |
| 427 | + process.env.TERM = 'dumb' |
| 428 | + delete process.env.TMUX |
| 429 | + delete process.env.STY |
| 430 | + |
| 431 | + clearClipboardMessage() |
| 432 | + unregisterClipboardRenderer() |
| 433 | + }) |
| 434 | + |
| 435 | + afterEach(() => { |
| 436 | + unregisterClipboardRenderer() |
| 437 | + if (originalPlatform) { |
| 438 | + Object.defineProperty(process, 'platform', originalPlatform) |
| 439 | + } |
| 440 | + for (const [key, value] of Object.entries(originalEnv)) { |
| 441 | + if (value !== undefined) process.env[key] = value |
| 442 | + else delete process.env[key] |
| 443 | + } |
| 444 | + loggerErrorSpy.mockRestore() |
| 445 | + clearClipboardMessage() |
| 446 | + }) |
| 447 | + |
| 448 | + test('renderer with copyToClipboardOSC52 returning true succeeds', async () => { |
| 449 | + const calls: string[] = [] |
| 450 | + registerClipboardRenderer({ |
| 451 | + copyToClipboardOSC52: (text: string) => { |
| 452 | + calls.push(text) |
| 453 | + return true |
| 454 | + }, |
| 455 | + }) |
| 456 | + |
| 457 | + await copyTextToClipboard('test text', { suppressGlobalMessage: true }) |
| 458 | + |
| 459 | + expect(calls).toEqual(['test text']) |
| 460 | + }) |
| 461 | + |
| 462 | + test('renderer with copyToClipboardOSC52 returning false falls through and fails', async () => { |
| 463 | + registerClipboardRenderer({ copyToClipboardOSC52: () => false }) |
| 464 | + |
| 465 | + await expect( |
| 466 | + copyTextToClipboard('test text', { suppressGlobalMessage: true }) |
| 467 | + ).rejects.toThrow('No clipboard method available') |
| 468 | + }) |
| 469 | + |
| 470 | + test('renderer without copyToClipboardOSC52 falls through and fails', async () => { |
| 471 | + registerClipboardRenderer({ someOtherMethod: () => true }) |
| 472 | + |
| 473 | + await expect( |
| 474 | + copyTextToClipboard('test text', { suppressGlobalMessage: true }) |
| 475 | + ).rejects.toThrow('No clipboard method available') |
| 476 | + }) |
| 477 | + |
| 478 | + test('renderer whose copyToClipboardOSC52 throws falls through gracefully', async () => { |
| 479 | + registerClipboardRenderer({ |
| 480 | + copyToClipboardOSC52: () => { throw new Error('renderer error') }, |
| 481 | + }) |
| 482 | + |
| 483 | + await expect( |
| 484 | + copyTextToClipboard('test text', { suppressGlobalMessage: true }) |
| 485 | + ).rejects.toThrow('No clipboard method available') |
| 486 | + }) |
| 487 | + |
| 488 | + test('unregisterClipboardRenderer removes renderer so it is no longer used', async () => { |
| 489 | + const calls: string[] = [] |
| 490 | + registerClipboardRenderer({ |
| 491 | + copyToClipboardOSC52: (text: string) => { |
| 492 | + calls.push(text) |
| 493 | + return true |
| 494 | + }, |
| 495 | + }) |
| 496 | + unregisterClipboardRenderer() |
| 497 | + |
| 498 | + await expect( |
| 499 | + copyTextToClipboard('test text', { suppressGlobalMessage: true }) |
| 500 | + ).rejects.toThrow('No clipboard method available') |
| 501 | + |
| 502 | + expect(calls).toEqual([]) |
| 503 | + }) |
| 504 | + |
| 505 | + test('renderer is tried in remote sessions (SSH) before manual OSC52', async () => { |
| 506 | + // Set up as remote session |
| 507 | + process.env.SSH_CLIENT = '192.168.1.100 54321 22' |
| 508 | + process.env.TERM = 'xterm-256color' |
| 509 | + |
| 510 | + const calls: string[] = [] |
| 511 | + registerClipboardRenderer({ |
| 512 | + copyToClipboardOSC52: () => { |
| 513 | + calls.push('renderer') |
| 514 | + return true |
| 515 | + }, |
| 516 | + }) |
| 517 | + |
| 518 | + await copyTextToClipboard('test text', { suppressGlobalMessage: true }) |
| 519 | + |
| 520 | + expect(calls).toEqual(['renderer']) |
| 521 | + }) |
| 522 | + |
| 523 | + test('shows success message when renderer copy succeeds', async () => { |
| 524 | + registerClipboardRenderer({ copyToClipboardOSC52: () => true }) |
| 525 | + |
| 526 | + const messages: (string | null)[] = [] |
| 527 | + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) |
| 528 | + |
| 529 | + await copyTextToClipboard('Hello world') |
| 530 | + |
| 531 | + expect(messages).toContain('Copied: "Hello world"') |
| 532 | + |
| 533 | + unsubscribe() |
| 534 | + }) |
| 535 | + }) |
| 536 | + |
402 | 537 | describe('copyTextToClipboard - SSH session detection behavior', () => { |
403 | 538 | // These tests verify the copy behavior changes based on SSH environment variables. |
404 | 539 | // In remote sessions (SSH), OSC52 is tried first; in local sessions, platform tools are tried first. |
|
0 commit comments