PrinterInfoModal.test.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. /**
  2. * Tests for PrinterInfoModal — focused on the CopyButton clipboard fallback
  3. * (#1174). Bambuddy is commonly deployed over plain HTTP on a LAN, where
  4. * `navigator.clipboard` is gated by the secure-context requirement and the
  5. * previous code (which only tried the modern API and silently swallowed the
  6. * failure) left both copy buttons inert.
  7. */
  8. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  9. import { screen, waitFor } from '@testing-library/react';
  10. import userEvent from '@testing-library/user-event';
  11. import { render } from '../utils';
  12. import { PrinterInfoModal } from '../../components/PrinterInfoModal';
  13. import type { Printer } from '../../api/client';
  14. function mockPrinter(): Printer {
  15. return {
  16. id: 1,
  17. name: 'Test P1S',
  18. serial_number: '01S00A123456789',
  19. ip_address: '192.168.1.42',
  20. access_code: '12345678',
  21. model: 'P1S',
  22. location: null,
  23. nozzle_count: 1,
  24. is_active: true,
  25. auto_archive: true,
  26. external_camera_url: null,
  27. external_camera_type: null,
  28. external_camera_enabled: false,
  29. camera_rotation: 0,
  30. plate_detection_enabled: false,
  31. created_at: '2026-01-01T00:00:00Z',
  32. updated_at: '2026-01-01T00:00:00Z',
  33. };
  34. }
  35. function getCopyButtons() {
  36. // CopyButton renders as an icon button with title="Copy to clipboard".
  37. return screen.getAllByRole('button').filter(
  38. btn => /copy/i.test(btn.getAttribute('title') || ''),
  39. );
  40. }
  41. describe('PrinterInfoModal — CopyButton clipboard fallback (#1174)', () => {
  42. let originalIsSecureContext: PropertyDescriptor | undefined;
  43. let originalClipboard: PropertyDescriptor | undefined;
  44. let originalExecCommand: typeof document.execCommand;
  45. beforeEach(() => {
  46. originalIsSecureContext = Object.getOwnPropertyDescriptor(window, 'isSecureContext');
  47. originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
  48. originalExecCommand = document.execCommand;
  49. });
  50. afterEach(() => {
  51. if (originalIsSecureContext) {
  52. Object.defineProperty(window, 'isSecureContext', originalIsSecureContext);
  53. }
  54. if (originalClipboard) {
  55. Object.defineProperty(navigator, 'clipboard', originalClipboard);
  56. }
  57. document.execCommand = originalExecCommand;
  58. vi.clearAllMocks();
  59. });
  60. it('uses navigator.clipboard.writeText in a secure context (HTTPS / localhost)', async () => {
  61. const user = userEvent.setup();
  62. const writeTextMock = vi.fn().mockResolvedValue(undefined);
  63. Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
  64. Object.defineProperty(navigator, 'clipboard', {
  65. value: { writeText: writeTextMock },
  66. configurable: true,
  67. });
  68. render(<PrinterInfoModal printer={mockPrinter()} onClose={() => {}} />);
  69. const buttons = getCopyButtons();
  70. expect(buttons.length).toBeGreaterThanOrEqual(2); // serial + ip at minimum
  71. // Click the first copy button (IP address row appears first).
  72. await user.click(buttons[0]);
  73. await waitFor(() => {
  74. expect(writeTextMock).toHaveBeenCalled();
  75. });
  76. // The value passed must be a real string from the printer fixture, not "".
  77. expect(writeTextMock.mock.calls[0][0]).toMatch(/^(192\.168\.1\.42|01S00A123456789)$/);
  78. });
  79. it('falls back to execCommand("copy") on plain-HTTP LAN deployments — pre-fix #1174 path', async () => {
  80. // Repro of the reporter's exact environment: plain-HTTP, no clipboard API.
  81. // Pre-fix the catch-block silently swallowed the TypeError on
  82. // navigator.clipboard.writeText and the icon never flipped to the tick.
  83. const user = userEvent.setup();
  84. Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
  85. Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
  86. const execCommandMock = vi.fn().mockReturnValue(true);
  87. document.execCommand = execCommandMock;
  88. render(<PrinterInfoModal printer={mockPrinter()} onClose={() => {}} />);
  89. const buttons = getCopyButtons();
  90. await user.click(buttons[0]);
  91. await waitFor(() => {
  92. expect(execCommandMock).toHaveBeenCalledWith('copy');
  93. });
  94. // Off-screen textarea must be cleaned up on the success path; otherwise
  95. // every click would leak a hidden DOM node.
  96. expect(document.querySelectorAll('textarea').length).toBe(0);
  97. });
  98. it('cleans up the off-screen textarea even when execCommand throws', async () => {
  99. // The fallback path uses a try/finally around execCommand. The finally
  100. // block must remove the textarea even if the browser rejects the copy
  101. // (e.g. permission denied), so a hostile / restricted environment doesn't
  102. // leak DOM nodes per click.
  103. const user = userEvent.setup();
  104. Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
  105. Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
  106. document.execCommand = vi.fn().mockImplementation(() => {
  107. throw new Error('synthetic execCommand failure');
  108. });
  109. render(<PrinterInfoModal printer={mockPrinter()} onClose={() => {}} />);
  110. const buttons = getCopyButtons();
  111. await user.click(buttons[0]);
  112. await waitFor(() => {
  113. expect(document.querySelectorAll('textarea').length).toBe(0);
  114. });
  115. });
  116. });