/** @vitest-environment jsdom */ import { afterEach, beforeEach, it, expect, vi } from 'vitest'; import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { en } from '../i18n/en'; import { it as italian } from '../i18n/it'; import { SharePanel } from './SharePanel'; import { buildShareMessage } from '../share'; const social = vi.hoisted(() => ({ composeShareImage: vi.fn(), downloadShareImage: vi.fn() })); vi.mock('../share', async original => ({ ...await original(), ...social })); const t = en.result.viralShare; const blob = new Blob(['CARD'], { type: 'image/png' }); const metadata = { itemName: 'Blue Dress', brandName: 'Merchant Atelier', productUrl: 'https://merchant.example/p?variant=7', productImage: 'https://cdn.test/thumb' }; let native: ReturnType, clipboard: ReturnType; beforeEach(() => { social.composeShareImage.mockReset().mockResolvedValue(blob); social.downloadShareImage.mockReset(); native = vi.fn().mockResolvedValue(undefined); clipboard = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, 'share', { configurable: true, value: native }); Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => true }); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: clipboard } }); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); }); function mount(url: string | null = metadata.productUrl, translations = t) { const intent = vi.fn(); const rendered = render(); fireEvent.click(screen.getByRole('button', { name: translations.title })); return { intent, ...rendered }; } async function ready(name = t.instagram) { const button = screen.getByRole('button', { name }); await waitFor(() => expect(button.hasAttribute('disabled')).toBe(false)); return button; } const channels = [['instagram_feed', 'instagram'], ['whatsapp_message', 'whatsapp']] as const; for (const mobile of [false, true]) for (const mode of ['file_text', 'file_only', 'none', 'absent']) for (const [channel, label] of channels) { it(`${channel} / ${mode} / ${mobile ? 'mobile' : 'desktop'}`, async () => { Object.defineProperty(navigator, 'userAgent', { configurable: true, value: mobile ? 'iPhone' : 'Desktop' }); Object.defineProperty(navigator, 'canShare', { configurable: true, value: mode === 'absent' ? undefined : (data: ShareData) => mode !== 'none' && (mode === 'file_text' || !data.text) }); const { intent } = mount(); fireEvent.click(await ready(t[label])); expect(clipboard).toHaveBeenCalledWith(buildShareMessage(metadata, channel)); if (mode === 'none' || mode === 'absent') { await screen.findByRole('status'); expect(screen.getByRole('status').textContent).toBe(t.downloadedMessageCopied); expect(social.downloadShareImage).toHaveBeenCalledWith(expect.objectContaining({ name: 'aisthetix-try-on-story.png', type: 'image/png', size: blob.size })); expect(native).not.toHaveBeenCalled(); } else { // Assertion is before any await after the click: native handoff is synchronous. expect(native).toHaveBeenCalledWith({ files: [expect.objectContaining({ name: 'aisthetix-try-on-story.png', type: 'image/png', size: blob.size })], ...(channel === 'whatsapp_message' && mode === 'file_text' ? { text: buildShareMessage(metadata, channel) } : {}) }); await ready(t[label]); expect(social.downloadShareImage).not.toHaveBeenCalled(); expect(screen.queryByRole('status')).toBeNull(); } expect(social.composeShareImage).toHaveBeenCalledTimes(1); expect(social.composeShareImage).toHaveBeenCalledWith('data:image/png;base64,RAW', metadata); const menu = document.querySelector('.aisthetix-share__menu') as HTMLElement; expect(within(menu).getAllByRole('button').map(button => button.getAttribute('aria-label'))).toEqual(['Instagram', 'WhatsApp', 'Download']); expect(screen.queryByRole('button', { name: 'Share image' })).toBeNull(); for (const [event] of intent.mock.calls) { expect(event.channel).toBe(channel); expect(['open_share_sheet', 'download_image', 'copy_product_link']).toContain(event.action); expect(['attempted', 'copied', 'downloaded', 'share_sheet_closed']).toContain(event.outcome); expect(Object.keys(event).sort()).toEqual(['action', 'capability', 'channel', 'outcome', 'productId', 'runId', 'variantId'].sort()); } }); } for (const [channel, label] of channels) for (const copyMode of ['pending', 'denied', 'throws', 'absent']) { it(`${channel} shares immediately with ${copyMode} clipboard`, async () => { if (copyMode === 'pending') clipboard.mockImplementation(() => new Promise(() => {})); if (copyMode === 'denied') clipboard.mockRejectedValue(Error('denied')); if (copyMode === 'throws') clipboard.mockImplementation(() => { throw Error('denied'); }); if (copyMode === 'absent') Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }); const { intent } = mount(); const button = await ready(t[label]); vi.useFakeTimers(); fireEvent.click(button); expect(native).toHaveBeenCalledTimes(1); if (copyMode !== 'absent') expect(clipboard.mock.invocationCallOrder[0]).toBeLessThan(native.mock.invocationCallOrder[0]); await act(async () => { await vi.advanceTimersByTimeAsync(2100); }); expect(intent).toHaveBeenCalledWith(expect.objectContaining({ action: 'copy_product_link', outcome: 'failed' })); expect(screen.queryByRole('status')).toBeNull(); expect(social.downloadShareImage).not.toHaveBeenCalled(); }); } it.each(['AbortError', 'Error'])('native %s never downloads; cancellation is silent', async name => { native.mockRejectedValue(new DOMException('no', name)); const { intent } = mount(); fireEvent.click(await ready(t.whatsapp)); await ready(t.whatsapp); expect(social.downloadShareImage).not.toHaveBeenCalled(); expect(intent).toHaveBeenCalledWith(expect.objectContaining({ action: 'open_share_sheet', outcome: name === 'AbortError' ? 'cancelled' : 'failed' })); if (name === 'AbortError') expect(screen.queryByRole('status')).toBeNull(); else expect(screen.getByRole('status').textContent).toBe(t.failed); }); it('all three actions reuse the prepared card, including missing URL', async () => { mount(null); fireEvent.click(await ready(t.instagram)); await ready(); fireEvent.click(await ready(t.whatsapp)); await ready(); fireEvent.click(await ready(t.downloadImage)); expect(native.mock.calls[0][0].files[0]).toBe(native.mock.calls[1][0].files[0]); expect(social.downloadShareImage).toHaveBeenCalledWith(native.mock.calls[0][0].files[0]); expect(clipboard).toHaveBeenCalledWith(buildShareMessage({ ...metadata, productUrl: null }, 'instagram_feed')); expect(social.composeShareImage).toHaveBeenCalledTimes(1); }); it.each([true, false])('fallback toast is truthful and transient (copied=%s)', async copied => { Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => false }); if (!copied) clipboard.mockRejectedValue(Error('denied')); mount(); const button = await ready(t.whatsapp); vi.useFakeTimers(); await act(async () => { fireEvent.click(button); }); expect(social.downloadShareImage.mock.invocationCallOrder[0]).toBeLessThan(clipboard.mock.invocationCallOrder[0]); expect(screen.getByRole('status').textContent).toBe(copied ? t.downloadedMessageCopied : t.downloadedCopyFailed); expect(document.querySelector('.aisthetix-share__menu [role="status"]')).toBeNull(); await act(async () => { await vi.advanceTimersByTimeAsync(2500); }); expect(screen.queryByRole('status')).toBeNull(); }); it('closing while fallback copy is pending cannot restore feedback', async () => { Object.defineProperty(navigator, 'canShare', { configurable: true, value: undefined }); let resolve!: () => void; clipboard.mockImplementation(() => new Promise(done => { resolve = done; })); mount(); fireEvent.click(await ready()); fireEvent.click(screen.getByRole('button', { name: t.title })); await act(async () => { resolve(); }); fireEvent.click(screen.getByRole('button', { name: t.title })); expect(screen.queryByRole('status')).toBeNull(); }); it('never offers the raw result when composition fails', async () => { social.composeShareImage.mockRejectedValue(Error('canvas')); mount(); await screen.findByText(t.unavailable); expect(screen.getByRole('button', { name: t.instagram }).hasAttribute('disabled')).toBe(true); expect(native).not.toHaveBeenCalled(); expect(social.downloadShareImage).not.toHaveBeenCalled(); }); it.each(['Instagram', 'Download'])('reports a failed download via %s', async name => { Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => false }); social.downloadShareImage.mockImplementation(() => { throw Error(); }); mount(); fireEvent.click(await ready(name)); expect(screen.getByRole('status').textContent).toBe(t.failed); }); it('renders the Italian buttons and fallback toast', async () => { Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => false }); mount(metadata.productUrl, italian.result.viralShare); fireEvent.click(await ready('WhatsApp')); expect((await screen.findByRole('status')).textContent).toBe('Immagine salvata, messaggio copiato'); }); it('keeps the gallery initiallyExpanded entry point', async () => { render(); await ready(); expect(document.querySelectorAll('.aisthetix-share__channel')).toHaveLength(3); });