// @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' );
} );
} );