/** @vitest-environment jsdom */ import { afterEach, describe, it, expect, vi } from 'vitest'; import * as card from './shareCard'; import { context } from './fixtures/canvasContext'; import cases from './fixtures/share-message-cases.json'; import { composeShareImage, buildShareMessage, shareClipboardText, shareFile, detectShareCapability, detectSharePayloadMode, openShareSheet, copyText, downloadShareImage, isMobileShareDevice, type ShareChannel } from './share'; const metadata={itemName:'Shirt',brandName:'Store',productUrl:'https://shop.test/p?variant=7'}; afterEach(()=>{vi.restoreAllMocks();vi.unstubAllGlobals();vi.useRealTimers();}); for (const fixture of cases) for (const channel of ['whatsapp_status','whatsapp_message','instagram_feed','instagram_stories'] as ShareChannel[]) { it(`${fixture.name}: ${channel} exact message`,()=>{ const expected=channel.startsWith('instagram_')?fixture.instagram:fixture.whatsapp; expect(buildShareMessage(fixture.metadata,channel)).toBe(expected); expect(shareClipboardText(fixture.metadata,channel)).toBe(expected); }); } function imageMock(broken = '') { const loaded: any[]=[]; class ImageMock { naturalWidth=1080;naturalHeight=1920;crossOrigin='';decoding='';onload=()=>{};onerror=()=>{}; set src(value:string){loaded.push({value,crossOrigin:this.crossOrigin});if(value.includes(broken) && broken)this.onerror();else this.onload();} } vi.stubGlobal('Image',ImageMock);return loaded; } function canvasMock(exporter: (cb:BlobCallback)=>void = cb=>cb(new Blob(['CARD'],{type:'image/png'}))) { const ctx=context();const canvas={width:0,height:0,getContext:()=>ctx,toBlob:vi.fn(exporter)}; Object.defineProperty(ctx, 'canvas', { value: canvas }); vi.spyOn(document,'createElement').mockReturnValue(canvas as any);return {canvas,ctx}; } describe('safe composition',()=>{ it('draws one 1080x1920 PNG and the watermark last; resolves protocol-relative thumbnail with CORS',async()=>{ const loaded=imageMock();const {canvas}=canvasMock();const draw=vi.spyOn(card,'drawShareCard'),stamp=vi.spyOn(card,'stampAiGeneratedWatermark'); const blob=await composeShareImage('data:image/png;base64,RAW',{...metadata,productImage:'//cdn.test/garment.png'}); expect(blob.type).toBe('image/png');expect([canvas.width,canvas.height]).toEqual([1080,1920]); expect(draw.mock.invocationCallOrder[0]).toBeLessThan(stamp.mock.invocationCallOrder[0]); expect(loaded[1]).toMatchObject({crossOrigin:'anonymous'});expect(loaded[1].value).toMatch(/^https?:\/\/cdn.test/); expect(canvas.toBlob).toHaveBeenCalledWith(expect.any(Function),'image/png'); }); it('exports the post size with the same compositor and bottom-lip watermark', async()=>{ imageMock(); const {canvas,ctx}=canvasMock(); await composeShareImage('data:image/png;base64,RAW',metadata,card.CARD_SIZES.post); expect([canvas.width,canvas.height]).toEqual([1080,1350]); expect(vi.mocked(ctx.fillText).mock.calls.at(-1)).toEqual(['ai generated',1056,1334]); }); it('uses placeholder after thumbnail load failure',async()=>{ imageMock('garment');canvasMock();const draw=vi.spyOn(card,'drawShareCard'); await composeShareImage('data:image/png;base64,RAW',{...metadata,productImage:'https://cdn.test/garment'}); expect(draw.mock.calls[0][2]).toBeNull(); }); it('recomposes once with placeholder on taint; rejects a second failure',async()=>{ imageMock();const {canvas}=canvasMock();const draw=vi.spyOn(card,'drawShareCard'); canvas.toBlob.mockImplementationOnce(()=>{throw new DOMException('taint','SecurityError');}); await composeShareImage('data:image/png;base64,RAW',{...metadata,productImage:'https://cdn.test/thumb'}); expect(draw.mock.calls[0][2]).not.toBeNull();expect(draw.mock.calls[1][2]).toBeNull(); canvas.toBlob.mockImplementation(()=>{throw new DOMException('taint','SecurityError');}); await expect(composeShareImage('data:image/png;base64,RAW',metadata)).rejects.toThrow('taint'); expect(canvas.toBlob).toHaveBeenCalledTimes(4); }); it('fails closed for decoding, canvas and export failure',async()=>{ imageMock('RAW');await expect(composeShareImage('data:image/png;base64,RAW',metadata)).rejects.toThrow('decoded'); imageMock();const {canvas}=canvasMock(cb=>cb(null)); await expect(composeShareImage('data:image/png;base64,RAW',metadata)).rejects.toThrow('export failed'); canvas.getContext=()=>null as any; await expect(composeShareImage('data:image/png;base64,RAW',metadata)).rejects.toThrow('canvas unavailable'); }); }); const file=new File(['CARD'],'card.png',{type:'image/png'}); it('never sends title, and tests file support before text',async()=>{ const share=vi.fn().mockResolvedValue(undefined);const nav={share,canShare:vi.fn(()=>true)} as any; expect(detectSharePayloadMode(file,metadata,nav,'whatsapp_status')).toBe('file_text'); await openShareSheet(file,metadata,nav); expect(share).toHaveBeenCalledWith({files:[file],text:buildShareMessage(metadata)}); await openShareSheet(file,metadata,nav,'instagram_feed',true); expect(share).toHaveBeenLastCalledWith({files:[file]}); expect(nav.canShare.mock.calls.every(([data]:[ShareData])=>!('title' in data))).toBe(true); }); it('detects file-only, unsupported, partial implementations and unavailable document',async()=>{ const share=vi.fn().mockResolvedValue(undefined);const nav={share,canShare:(data:ShareData)=>!data.text} as any; expect(detectSharePayloadMode(file,metadata,nav)).toBe('file_only');await openShareSheet(file,metadata,nav);expect(share).toHaveBeenCalledWith({files:[file]}); for(const canShare of [()=>false,()=>{throw Error('partial');}]) { expect(detectSharePayloadMode(file,metadata,{share,canShare} as any)).toBe('none'); } expect(detectShareCapability(file,{} as Navigator)).toBe('download_only'); expect(detectShareCapability(file,{clipboard:{writeText:vi.fn()}} as any)).toBe('download_clipboard'); expect(await openShareSheet(file,metadata,{} as Navigator)).toMatchObject({outcome:'fallback_ready'}); vi.stubGlobal('document',undefined);expect(detectShareCapability(file,{} as Navigator)).toBe('unavailable'); }); it.each([['AbortError','cancelled'],['Error','failed']])('handles %s without throwing',async(name,outcome)=>{ const nav={share:vi.fn().mockRejectedValue(new DOMException('no',name)),canShare:()=>true} as any; expect(await openShareSheet(file,metadata,nav)).toMatchObject({outcome}); }); it('copies, denies, and bounds pending clipboard at two seconds',async()=>{ expect(await copyText('message',{} as Navigator)).toBe(false); expect(await copyText('message',{clipboard:{writeText:vi.fn().mockRejectedValue(Error())}} as any)).toBe(false); const writeText=vi.fn().mockResolvedValue(undefined);expect(await copyText('message',{clipboard:{writeText}} as any)).toBe(true);expect(writeText).toHaveBeenCalledWith('message'); vi.useFakeTimers();const copying=copyText('x',{clipboard:{writeText:()=>new Promise(()=>{})}} as any);await vi.advanceTimersByTimeAsync(2000);expect(await copying).toBe(false);expect(vi.getTimerCount()).toBe(0); }); it('downloads only the composed PNG, with deferred URL cleanup',()=>{ vi.useFakeTimers();const createObjectURL=vi.fn(()=> 'blob:card'), revokeObjectURL=vi.fn();vi.stubGlobal('URL',{createObjectURL,revokeObjectURL}); let anchor:HTMLAnchorElement;vi.spyOn(HTMLAnchorElement.prototype,'click').mockImplementation(function(this:HTMLAnchorElement){anchor=this;expect(this.isConnected).toBe(true);}); const blob=new Blob(['CARD']);downloadShareImage(blob);expect(anchor!.download).toBe('aisthetix-try-on-story.png');expect(anchor!.isConnected).toBe(false); expect(shareFile(blob).name).toBe('aisthetix-try-on-story.png');vi.advanceTimersByTime(30000);expect(revokeObjectURL).toHaveBeenCalledWith('blob:card'); }); it.each([[{userAgentData:{mobile:false}},false],[{userAgentData:{mobile:true}},true],[{userAgent:'iPhone'},true],[{userAgent:'Macintosh',maxTouchPoints:5},true],[{},false]])('detects device %j',(nav,expected)=>expect(isMobileShareDevice(nav as Navigator)).toBe(expected)); it('builds transient messages without mutating metadata or browser storage', () => { const stored=vi.spyOn(Storage.prototype,'setItem'); const frozen=Object.freeze({...metadata}); for(const channel of ['instagram_stories','instagram_feed','whatsapp_status','whatsapp_message'] as ShareChannel[]) shareClipboardText(frozen,channel); expect(stored).not.toHaveBeenCalled();expect(frozen.productUrl).toBe('https://shop.test/p?variant=7'); });