// @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 } 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: () =>
, } ) ); 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 ); 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 ); 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 ); 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 ); 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 ); 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 ); await waitFor( () => expect( screen.getByTestId( 'avatar-upload' ) ).toBeTruthy() ); expect( screen.queryByText( /try-ons? left/ ) ).toBeNull(); } ); } ); describe( 'the result, on the demo', () => { it( 'puts the garment on the render as a thumbnail', async () => { seedReturningVisitor(); const trigger = mount( DEMO ); await click( trigger ); await waitFor( () => { const pip = document.querySelector( '.aisthetix-tryon-figure-pip' ); expect( pip?.getAttribute( 'src' ) ).toBe( 'data:image/png;base64,GARMENT' ); } ); } ); it( 'files the try-on in the gallery, so it survives the modal closing', async () => { seedReturningVisitor(); const trigger = mount( DEMO ); await click( trigger ); 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 ); 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 ); 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 ); 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 ); 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 ); 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' ); } ); } );