// @vitest-environment jsdom
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 type { WidgetConfig } from './types';
// Shared spies must exist before the hoisted vi.mock factories run.
const { tryOnSync, fetchImageAsBase64, trackTryOnCompleted, compressGarmentImage } = vi.hoisted( () => ( {
tryOnSync: vi.fn(),
fetchImageAsBase64: vi.fn(),
trackTryOnCompleted: vi.fn(),
compressGarmentImage: vi.fn(),
} ) );
// new VirtualTryOnAPI(...) returns the object below, so the App's `api.tryOnSync`
// is our spy. storage is intentionally NOT mocked — hasConsent() reads the real
// jsdom localStorage, which is what the gate depends on.
vi.mock( './api', () => ( {
VirtualTryOnAPI: vi.fn( () => ( { tryOnSync } ) ),
QuotaExhaustedError: class QuotaExhaustedError extends Error {},
VisitorRateLimitedError: class VisitorRateLimitedError extends Error {},
} ) );
vi.mock( './imageUtils', () => ( {
fetchImageAsBase64,
compressGarmentImage,
// Real class so `instanceof` works across the mock boundary.
UndecodableImageError: class UndecodableImageError extends Error {},
} ) );
vi.mock( './events', () => ( { trackTryOnCompleted } ) );
// Render sub-views as markers so the test isolates App's state machine + gate.
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: ( { message }: { message?: string } ) => { message }
,
} ) );
vi.mock( './components/QuotaBlockedView', () => ( { QuotaBlockedView: () => } ) );
const CONFIG: WidgetConfig = {
apiBaseUrl: 'https://brain.test/api/storefront/tryon',
publishableKey: 'pk_test',
productImage: 'https://shop.test/garment.png',
productTitle: 'Test Garment',
productId: 'prod_1',
productCategories: [],
visitorKey: 'test-visitor',
locale: 'en',
};
const AVATAR = { imageData: 'data:image/png;base64,AVATAR', createdAt: '2026-01-01T00:00:00Z' };
// A stored avatar from a prior visit — the returning-visitor path that skips
// AvatarUpload and lands straight in startTryOnWithAvatar (the funnel under test).
function seedAvatar(): void {
localStorage.setItem( 'aisthetix-avatar', JSON.stringify( AVATAR ) );
}
function mountWithTrigger(): HTMLButtonElement {
const trigger = document.createElement( 'button' );
trigger.id = 'aisthetix-tryon-trigger';
document.body.appendChild( trigger );
render( );
return trigger;
}
beforeEach( () => {
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 );
} );
afterEach( () => {
cleanup();
document.body.innerHTML = '';
} );
describe( 'consent gate before AI try-on generation (startTryOnWithAvatar funnel)', () => {
it( 'returning visitor WITHOUT consent: trigger routes to consent screen and never generates', async () => {
seedAvatar(); // avatar present, but consent NOT set -> hasConsent() === false
const trigger = mountWithTrigger();
await act( async () => {
fireEvent.click( trigger );
} );
await waitFor( () => expect( screen.getByTestId( 'avatar-upload' ) ).toBeTruthy() );
expect( tryOnSync ).not.toHaveBeenCalled();
expect( fetchImageAsBase64 ).not.toHaveBeenCalled();
} );
it( 'WITH consent: the same trigger path proceeds to generation and calls tryOnSync', async () => {
seedAvatar();
setConsent(); // hasConsent() === true
const trigger = mountWithTrigger();
await act( async () => {
fireEvent.click( trigger );
} );
await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) );
expect( tryOnSync ).toHaveBeenCalledWith(
AVATAR.imageData,
'data:image/png;base64,GARMENT',
''
);
} );
it( 'compresses the garment before sending it to try-on', async () => {
seedAvatar();
setConsent();
compressGarmentImage.mockResolvedValue( 'data:image/jpeg;base64,COMPRESSED_GARMENT' );
const trigger = mountWithTrigger();
await act( async () => {
fireEvent.click( trigger );
} );
await waitFor( () => expect( tryOnSync ).toHaveBeenCalledTimes( 1 ) );
expect( compressGarmentImage ).toHaveBeenCalledWith( 'data:image/png;base64,GARMENT' );
expect( tryOnSync ).toHaveBeenCalledWith(
AVATAR.imageData,
'data:image/jpeg;base64,COMPRESSED_GARMENT',
''
);
} );
it( 'shows the clothing-image error copy when the garment fails to decode', async () => {
seedAvatar();
setConsent();
const { UndecodableImageError } = await import( './imageUtils' );
compressGarmentImage.mockRejectedValue( new ( UndecodableImageError as any )() );
const trigger = mountWithTrigger();
await act( async () => {
fireEvent.click( trigger );
} );
await waitFor( () =>
expect( screen.getByTestId( 'error' ).textContent ).toBe(
'Could not load the clothing image. Please try again.'
)
);
expect( tryOnSync ).not.toHaveBeenCalled();
} );
} );