mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 20:56:18 +02:00
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.
137 lines
5.2 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|