// @vitest-environment jsdom import 'fake-indexeddb/auto'; import type { ReactNode } from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act, render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; import { App } from './App'; import { setConsent } from './storage'; import { clearTryOns, listTryOns } from './demo/tryOnStore'; import { en } from './i18n/en'; import type { WidgetConfig } from './types'; const { tryOnSync, fetchImageAsBase64, trackTryOnCompleted, compressGarmentImage } = vi.hoisted( () => ( { tryOnSync: vi.fn(), fetchImageAsBase64: vi.fn(), trackTryOnCompleted: vi.fn(), compressGarmentImage: vi.fn(), } ) ); vi.mock( './api', () => ( { VirtualTryOnAPI: vi.fn( () => ( { tryOnSync } ) ), QuotaExhaustedError: class QuotaExhaustedError extends Error {}, VisitorRateLimitedError: class VisitorRateLimitedError extends Error {}, } ) ); vi.mock( './imageUtils', () => ( { fetchImageAsBase64, compressGarmentImage, downloadImage: vi.fn(), UndecodableImageError: class UndecodableImageError extends Error {}, } ) ); vi.mock( './events', () => ( { trackTryOnCompleted } ) ); // Only the shell and the screens that are not under test are stubbed. The demo // components render for real, so these assertions are about what a visitor sees. vi.mock( './components/Modal', () => ( { Modal: ( { isOpen, children }: { isOpen: boolean; children: ReactNode } ) => isOpen ?
{ children }
: null, } ) ); vi.mock( './components/AvatarUpload', () => ( { AvatarUpload: ({initialPreview, onAvatarSaved}: {initialPreview?: string; onAvatarSaved: (image: string) => void}) =>
{initialPreview && }
, } ) ); vi.mock( './components/ProcessingView', () => ( { ProcessingView: () =>
} ) ); vi.mock( './components/ResultView', () => ( { ResultView: () =>
} ) ); vi.mock( './components/ErrorView', () => ( { ErrorView: () =>
} ) ); vi.mock( './components/QuotaBlockedView', () => ( { QuotaBlockedView: () =>
} ) ); const MERCHANT: WidgetConfig = { apiBaseUrl: 'https://brain.test/api/storefront/tryon', publishableKey: 'pk_test', productImage: 'https://shop.test/garment.png', productTitle: 'Amalfi Linen Dress', productId: 'prod_1', productUrl: 'https://shop.test/product/amalfi', locale: 'en', }; const DEMO: WidgetConfig = { ...MERCHANT, demo: true, demoTryOnLimit: 5, demoLeadUrl: '/wp-json/vela-demo/v1/lead', }; const AVATAR = { imageData: 'data:image/png;base64,AVATAR', createdAt: '2026-01-01T00:00:00Z' }; function seedReturningVisitor(): void { localStorage.setItem( 'aisthetix-avatar', JSON.stringify( AVATAR ) ); setConsent(); } function mount( config: WidgetConfig ): HTMLButtonElement { const trigger = document.createElement( 'button' ); trigger.id = 'aisthetix-tryon-trigger'; document.body.appendChild( trigger ); render( ); return trigger; } async function click( element: HTMLElement ): Promise { await act( async () => { fireEvent.click( element ); } ); } beforeEach( async () => { // tryOnStore memoizes its connection, so swapping in a fresh IDBFactory would // not actually give this file a fresh database. Emptying the store does. await clearTryOns(); localStorage.clear(); tryOnSync.mockReset().mockResolvedValue( { image: 'RESULT_BASE64', cached: false } ); fetchImageAsBase64.mockReset().mockResolvedValue( 'data:image/png;base64,GARMENT' ); trackTryOnCompleted.mockReset(); compressGarmentImage.mockReset().mockImplementation( async ( x: string ) => x ); globalThis.fetch = vi.fn().mockResolvedValue( { ok: true } ) as unknown as typeof fetch; } ); afterEach( () => { cleanup(); document.body.innerHTML = ''; Reflect.deleteProperty( globalThis, 'fetch' ); } ); // The demo customizations ship in the same bundle every merchant gets. The // only thing keeping them out of a real storefront is this flag, so it is // worth a test of its own. describe( 'a merchant store, with the demo flag absent', () => { it( 'shows no counter and no way into a gallery', async () => { seedReturningVisitor(); const trigger = mount( MERCHANT ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( screen.getByTestId( 'merchant-result' ) ).toBeTruthy() ); expect( document.querySelector( '.aisthetix-modal-toolbar' ) ).toBeNull(); expect( screen.queryByText( en.gallery.open ) ).toBeNull(); } ); it( 'keeps the merchant result screen, with no garment thumbnail on it', async () => { seedReturningVisitor(); const trigger = mount( MERCHANT ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( screen.getByTestId( 'merchant-result' ) ).toBeTruthy() ); expect( document.querySelector( '.aisthetix-tryon-figure-pip' ) ).toBeNull(); } ); it( 'never asks for an email, however many try-ons were made', async () => { localStorage.setItem( 'aisthetix-demo-tryons', '4' ); seedReturningVisitor(); const trigger = mount( MERCHANT ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) ); expect( screen.queryByText( en.lead.purpose ) ).toBeNull(); } ); } ); describe( 'the counter, on the demo', () => { // "Before you start", not "when you run out": a visitor uploading their // first photo already knows what the demo gives them. it( 'is on screen before the first try-on', async () => { const trigger = mount( DEMO ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( screen.getByTestId( 'avatar-upload' ) ).toBeTruthy() ); expect( screen.getByText( '5 try-ons left' ) ).toBeTruthy(); } ); it( 'counts down after a try-on', async () => { seedReturningVisitor(); const trigger = mount( DEMO ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( screen.getByText( '4 try-ons left' ) ).toBeTruthy() ); } ); it( 'says nothing when the demo advertised no limit', async () => { const trigger = mount( { ...DEMO, demoTryOnLimit: undefined } ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( screen.getByTestId( 'avatar-upload' ) ).toBeTruthy() ); expect( screen.queryByText( /try-ons? left/ ) ).toBeNull(); } ); } ); describe( 'the result, on the demo', () => { it( 'compares the confirmed shopper photo with the result and preserves the split', async () => { seedReturningVisitor(); const trigger = mount( DEMO ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); const slider = await screen.findByRole('slider', { name: en.result.beforeAfterSlider }); expect(slider.getAttribute('value')).toBe('50'); expect(screen.getByRole('img', { name: en.result.beforePhoto }).getAttribute('src')).toBe(AVATAR.imageData); expect(screen.getByRole('img', { name: en.result.afterTryOn }).getAttribute('src')).toBe('data:image/png;base64,RESULT_BASE64'); fireEvent.change(slider, { target: { value: '64' } }); await click(screen.getByRole('button', { name: en.result.toggleCompare })); expect(screen.queryByRole('slider')).toBeNull(); expect(screen.getByRole('img', { name: en.result.compareOriginal }).getAttribute('src')).toBe('data:image/png;base64,GARMENT'); await click(screen.getByRole('button', { name: en.result.toggleResult })); expect(screen.getByRole('slider').getAttribute('value')).toBe('64'); expect(tryOnSync).toHaveBeenCalledTimes(1); expect(screen.getByRole('button', { name: en.result.tryDifferent })).toBeTruthy(); await click(screen.getByRole('button', { name: en.result.continueShopping })); expect(screen.queryByTestId('modal')).toBeNull(); } ); it( 'files the try-on in the gallery, so it survives the modal closing', async () => { seedReturningVisitor(); const trigger = mount( DEMO ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( screen.getByText( en.gallery.openWithCount( 1 ) ) ).toBeTruthy() ); await click( screen.getByText( en.gallery.openWithCount( 1 ) ) ); await waitFor( () => expect( screen.getByText( 'Amalfi Linen Dress' ) ).toBeTruthy() ); expect( screen.getByRole( 'link', { name: en.gallery.viewProduct } ).getAttribute( 'href' ) ).toBe( 'https://shop.test/product/amalfi' ); // The render came back out of IndexedDB, garment thumbnail and all. expect( document.querySelector( '.aisthetix-gallery-item .aisthetix-tryon-figure-pip' ) ).toBeTruthy(); } ); } ); describe( 'the email gate, on the demo', () => { it( 'leaves the first try-on alone', async () => { seedReturningVisitor(); const trigger = mount( DEMO ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) ); expect( screen.queryByText( en.lead.purpose ) ).toBeNull(); } ); it( 'stops the second one and states why it is asking', async () => { localStorage.setItem( 'aisthetix-demo-tryons', '1' ); seedReturningVisitor(); const trigger = mount( DEMO ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( screen.getByText( en.lead.purpose ) ).toBeTruthy() ); expect( tryOnSync ).not.toHaveBeenCalled(); } ); it( 'runs the interrupted try-on once the address is given', async () => { localStorage.setItem( 'aisthetix-demo-tryons', '1' ); seedReturningVisitor(); const trigger = mount( DEMO ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( screen.getByText( en.lead.purpose ) ).toBeTruthy() ); fireEvent.change( screen.getByLabelText( en.lead.emailLabel ), { target: { value: 'buyer@store.co' }, } ); fireEvent.click( screen.getByRole( 'checkbox' ) ); await click( screen.getByRole( 'button', { name: en.lead.submit } ) ); await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) ); expect( localStorage.getItem( 'aisthetix-demo-lead' ) ).toBe( 'buyer@store.co' ); } ); it( 'does not generate anything when the lead could not be saved', async () => { globalThis.fetch = vi.fn().mockResolvedValue( { ok: false } ) as unknown as typeof fetch; localStorage.setItem( 'aisthetix-demo-tryons', '1' ); seedReturningVisitor(); const trigger = mount( DEMO ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( screen.getByText( en.lead.purpose ) ).toBeTruthy() ); fireEvent.change( screen.getByLabelText( en.lead.emailLabel ), { target: { value: 'buyer@store.co' }, } ); fireEvent.click( screen.getByRole( 'checkbox' ) ); await click( screen.getByRole( 'button', { name: en.lead.submit } ) ); await waitFor( () => expect( screen.getByRole( 'alert' ).textContent ).toBe( en.lead.error ) ); expect( tryOnSync ).not.toHaveBeenCalled(); expect( localStorage.getItem( 'aisthetix-demo-lead' ) ).toBeNull(); } ); // The way out has to cost nothing and take nothing away. it( 'lets the visitor leave without an address, keeping what they made', async () => { localStorage.setItem( 'aisthetix-demo-tryons', '1' ); seedReturningVisitor(); const trigger = mount( DEMO ); await click( trigger ); expect(tryOnSync).not.toHaveBeenCalled(); expect(screen.queryByText(en.lead.purpose)).toBeNull(); if (localStorage.getItem('aisthetix-avatar')) await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor( () => expect( screen.getByText( en.lead.purpose ) ).toBeTruthy() ); await click( screen.getByRole( 'button', { name: en.lead.skip } ) ); expect( tryOnSync ).not.toHaveBeenCalled(); expect( localStorage.getItem( 'aisthetix-demo-tryons' ) ).toBe( '1' ); } ); } ); it('share landing consumes the flag after readiness and confirms a saved photo without generating', async () => { seedReturningVisitor(); window.AISTHETIX_SHARE_LANDING = true; mount(DEMO); await screen.findByTestId('avatar-upload'); expect(window.AISTHETIX_WIDGET_READY).toBe(true); expect(window.AISTHETIX_SHARE_LANDING).toBeUndefined(); expect(tryOnSync).not.toHaveBeenCalled(); expect(fetchImageAsBase64).not.toHaveBeenCalled(); }); it('saves a clean gallery URL; the share marker never enters history', async () => { seedReturningVisitor(); const trigger=mount({...DEMO, productUrl:'https://shop.test/product/amalfi?atx=1&ATX=0&utm_source=x'}); await click(trigger); await click(screen.getByRole('button', {name: 'Use This Photo'})); await waitFor(async()=>expect(await listTryOns()).toHaveLength(1)); expect((await listTryOns())[0].productUrl).toBe('https://shop.test/product/amalfi'); });