Files
bambuddy/frontend/src/__tests__/components/PrinterInfoModal.test.tsx
T
maziggy 59c58362ba fix(printers): copy buttons in Printer Info modal work on plain HTTP (#1174)
PrinterInfoModal's CopyButton only tried navigator.clipboard.writeText(),
  which is gated by the secure-context requirement (HTTPS or localhost).
  On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN —
  navigator.clipboard is undefined; the existing try/catch swallowed the
  TypeError, the icon never flipped to the tick, and nothing landed on
  the user's clipboard.

  Fixed by adding the same off-screen-textarea + document.execCommand('copy')
  fallback that CameraTokensPage's plaintext-token modal already uses for
  plain-HTTP LAN deployments. Gate on `navigator.clipboard && window.isSecureContext`,
  fall back to the legacy path otherwise, and surface the success-tick only
  when the copy actually landed (return early without flipping `copied` if
  execCommand returns false). The try/finally around the textarea guarantees
  DOM cleanup even when the browser throws on a restricted context.

  3 new component tests in PrinterInfoModal.test.tsx cover the secure-context
  happy path (navigator.clipboard.writeText is called with the correct value),
  the plain-HTTP fallback path (execCommand is invoked, no leaked textarea
  left in the DOM), and the finally cleanup when execCommand throws
  synthetically.
2026-05-01 08:21:37 +02:00

137 lines
5.2 KiB
TypeScript

/**
* Tests for PrinterInfoModal — focused on the CopyButton clipboard fallback
* (#1174). Bambuddy is commonly deployed over plain HTTP on a LAN, where
* `navigator.clipboard` is gated by the secure-context requirement and the
* previous code (which only tried the modern API and silently swallowed the
* failure) left both copy buttons inert.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { PrinterInfoModal } from '../../components/PrinterInfoModal';
import type { Printer } from '../../api/client';
function mockPrinter(): Printer {
return {
id: 1,
name: 'Test P1S',
serial_number: '01S00A123456789',
ip_address: '192.168.1.42',
access_code: '12345678',
model: 'P1S',
location: null,
nozzle_count: 1,
is_active: true,
auto_archive: true,
external_camera_url: null,
external_camera_type: null,
external_camera_enabled: false,
camera_rotation: 0,
plate_detection_enabled: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
}
function getCopyButtons() {
// CopyButton renders as an icon button with title="Copy to clipboard".
return screen.getAllByRole('button').filter(
btn => /copy/i.test(btn.getAttribute('title') || ''),
);
}
describe('PrinterInfoModal — CopyButton clipboard fallback (#1174)', () => {
let originalIsSecureContext: PropertyDescriptor | undefined;
let originalClipboard: PropertyDescriptor | undefined;
let originalExecCommand: typeof document.execCommand;
beforeEach(() => {
originalIsSecureContext = Object.getOwnPropertyDescriptor(window, 'isSecureContext');
originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
originalExecCommand = document.execCommand;
});
afterEach(() => {
if (originalIsSecureContext) {
Object.defineProperty(window, 'isSecureContext', originalIsSecureContext);
}
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
}
document.execCommand = originalExecCommand;
vi.clearAllMocks();
});
it('uses navigator.clipboard.writeText in a secure context (HTTPS / localhost)', async () => {
const user = userEvent.setup();
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: writeTextMock },
configurable: true,
});
render(<PrinterInfoModal printer={mockPrinter()} onClose={() => {}} />);
const buttons = getCopyButtons();
expect(buttons.length).toBeGreaterThanOrEqual(2); // serial + ip at minimum
// Click the first copy button (IP address row appears first).
await user.click(buttons[0]);
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalled();
});
// The value passed must be a real string from the printer fixture, not "".
expect(writeTextMock.mock.calls[0][0]).toMatch(/^(192\.168\.1\.42|01S00A123456789)$/);
});
it('falls back to execCommand("copy") on plain-HTTP LAN deployments — pre-fix #1174 path', async () => {
// Repro of the reporter's exact environment: plain-HTTP, no clipboard API.
// Pre-fix the catch-block silently swallowed the TypeError on
// navigator.clipboard.writeText and the icon never flipped to the tick.
const user = userEvent.setup();
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
const execCommandMock = vi.fn().mockReturnValue(true);
document.execCommand = execCommandMock;
render(<PrinterInfoModal printer={mockPrinter()} onClose={() => {}} />);
const buttons = getCopyButtons();
await user.click(buttons[0]);
await waitFor(() => {
expect(execCommandMock).toHaveBeenCalledWith('copy');
});
// Off-screen textarea must be cleaned up on the success path; otherwise
// every click would leak a hidden DOM node.
expect(document.querySelectorAll('textarea').length).toBe(0);
});
it('cleans up the off-screen textarea even when execCommand throws', async () => {
// The fallback path uses a try/finally around execCommand. The finally
// block must remove the textarea even if the browser rejects the copy
// (e.g. permission denied), so a hostile / restricted environment doesn't
// leak DOM nodes per click.
const user = userEvent.setup();
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
document.execCommand = vi.fn().mockImplementation(() => {
throw new Error('synthetic execCommand failure');
});
render(<PrinterInfoModal printer={mockPrinter()} onClose={() => {}} />);
const buttons = getCopyButtons();
await user.click(buttons[0]);
await waitFor(() => {
expect(document.querySelectorAll('textarea').length).toBe(0);
});
});
});