2026-04-12 19:31:12 +02:00
const { test , expect } = require ( '@playwright/test' ) ;
const {
EXPECTED _MATCH _COUNT ,
FACEAI _BASE _URL ,
buildHandoffUrl ,
buildSimulatorUrl ,
getSearchArtifacts ,
getSelfiePath ,
readUtf8
} = require ( './faceai-test-utils' ) ;
const FACEAI _HOME _URL _RE = /http:\/\/(localhost|127\.0\.0\.1):3001\/?(?:\?.*)?$/ ;
const FACEAI _CALLBACK _URL _RE = /http:\/\/(localhost|127\.0\.0\.1):3001\/auth\/callback\?token=/ ;
const FACEAI _RETURN _URL _RE = /http:\/\/(localhost|127\.0\.0\.1):8080\/faceai_return\.php\?resultId=.*token=.*/ ;
const LEGACY _HOME _URL _RE = /http:\/\/(localhost|127\.0\.0\.1):8080\/index\.jsp$/ ;
function buildLegacySimulatorReturnMatcher ( raceId ) {
return new RegExp ( ` http: \\ / \\ /(localhost|127 \\ .0 \\ .0 \\ .1):8080 \\ /faceai_simulator \\ .php \\ ?raceId= ${ raceId } .* ` ) ;
}
function assertLogDoesNotContain ( content , patterns , label ) {
for ( const pattern of patterns ) {
expect ( content , ` ${ label } should not contain ${ pattern } ` ) . not . toMatch ( pattern ) ;
}
}
async function waitForFaceAiHome ( page ) {
await page . waitForURL ( ( url ) => FACEAI _CALLBACK _URL _RE . test ( url . toString ( ) ) || FACEAI _HOME _URL _RE . test ( url . toString ( ) ) , {
timeout : 60 * 1000
} ) ;
await expect ( page . getByRole ( 'heading' , { name : 'Trova le tue foto con un selfie' } ) ) . toBeVisible ( ) ;
}
async function launchFromSimulator ( page , options = { } ) {
const simulatorUrl = buildSimulatorUrl ( options ) ;
await page . goto ( simulatorUrl , { waitUntil : 'domcontentloaded' } ) ;
await expect ( page . locator ( '#faceaiLaunchButton' ) ) . toBeVisible ( ) ;
await expect ( page . locator ( 'select#tipoPuntoFoto' ) ) . toHaveCount ( 0 ) ;
await page . locator ( '#faceaiLaunchButton' ) . click ( ) ;
await waitForFaceAiHome ( page ) ;
return simulatorUrl ;
}
async function enterViaHandoff ( page , options = { } ) {
await page . goto ( buildHandoffUrl ( options ) , { waitUntil : 'domcontentloaded' } ) ;
await waitForFaceAiHome ( page ) ;
}
async function startSearch ( page , selfieName ) {
const createResponsePromise = page . waitForResponse ( ( response ) => {
return response . url ( ) . includes ( '/api/searches' )
&& response . request ( ) . method ( ) === 'POST'
&& response . status ( ) === 201 ;
} ) ;
await page . locator ( 'input[type="file"]' ) . setInputFiles ( getSelfiePath ( selfieName ) ) ;
await expect ( page . getByText ( selfieName ) ) . toBeVisible ( ) ;
await page . getByRole ( 'button' , { name : 'Avvia ricerca Face ID' } ) . click ( ) ;
const createResponse = await createResponsePromise ;
return createResponse . json ( ) ;
}
async function fetchSearchStatus ( page , searchId ) {
return page . evaluate ( async ( { searchId } ) => {
const response = await fetch ( ` /api/searches/ ${ searchId } ` , { credentials : 'include' } ) ;
const body = await response . json ( ) . catch ( ( ) => null ) ;
return {
statusCode : response . status ,
body
} ;
} , { searchId } ) ;
}
async function waitForSearchCondition ( page , searchId , predicate , timeoutMs = 30 * 1000 ) {
const deadline = Date . now ( ) + timeoutMs ;
let lastPayload = null ;
while ( Date . now ( ) < deadline ) {
const payload = await fetchSearchStatus ( page , searchId ) ;
lastPayload = payload ;
if ( payload . statusCode === 200 && predicate ( payload . body ) ) {
return payload . body ;
}
await page . waitForTimeout ( 250 ) ;
}
throw new Error ( ` Timed out waiting for search ${ searchId } . Last payload: ${ JSON . stringify ( lastPayload ) } ` ) ;
}
async function waitForLegacyResult ( page , expectedMatchCount = null ) {
await page . waitForURL ( FACEAI _RETURN _URL _RE , {
timeout : 6 * 60 * 1000
} ) ;
await expect ( page . locator ( '.sim-banner' ) ) . toContainText ( 'Vista filtrata da FaceAI' ) ;
if ( expectedMatchCount === null ) {
await expect ( page . locator ( '.gallery-card' ) . first ( ) ) . toBeVisible ( ) ;
return ;
}
await expect ( page . locator ( '.sim-banner' ) ) . toContainText ( String ( expectedMatchCount ) ) ;
await expect ( page . locator ( '.gallery-card' ) ) . toHaveCount ( expectedMatchCount ) ;
}
async function verifySearchLogs ( searchId , { expectedMatchCount , expectedSelfieName } ) {
const artifacts = getSearchArtifacts ( searchId ) ;
const [ backendLog , processorLog , workerLog , matcherLog ] = await Promise . all ( [
readUtf8 ( artifacts . backendLogPath ) ,
readUtf8 ( artifacts . processorLogPath ) ,
readUtf8 ( artifacts . workerLogPath ) ,
readUtf8 ( artifacts . matcherLogPath )
] ) ;
expect ( workerLog ) . toContain ( 'Completed FaceAI search' ) ;
if ( expectedMatchCount !== undefined ) {
expect ( workerLog ) . toContain ( ` "matchCount": ${ expectedMatchCount } ` ) ;
}
if ( expectedSelfieName ) {
expect ( matcherLog ) . toContain ( expectedSelfieName ) ;
}
assertLogDoesNotContain ( backendLog , [ /\bnpm error\b/i , /\berror:\b/i , /\bfailed\b/i ] , 'backend.log' ) ;
assertLogDoesNotContain ( processorLog , [ new RegExp ( ` Failed FaceAI search ${ searchId } ` , 'i' ) , /\bnpm error\b/i ] , 'processor.log' ) ;
assertLogDoesNotContain ( workerLog , [ /FaceAI search failed/i ] , 'worker.log' ) ;
assertLogDoesNotContain ( matcherLog , [ /\[ERROR\]/i , /Traceback/i ] , 'matcher.log' ) ;
return { backendLog , processorLog , workerLog , matcherLog } ;
}
async function closeContexts ( contexts ) {
await Promise . all ( contexts . map ( ( context ) => context . close ( ) ) ) ;
}
test ( 'runs the simulator flow through FaceAI and returns to the filtered legacy result' , async ( { page } ) => {
await launchFromSimulator ( page , {
raceId : '202' ,
raceSlug : 'mezza-di-pisa' ,
raceName : 'Mezza di Pisa' ,
raceFolder : 'PISA'
} ) ;
const search = await startSearch ( page , 'DSC_1960.JPG' ) ;
await waitForLegacyResult ( page , EXPECTED _MATCH _COUNT ) ;
await expect ( page . locator ( '.gallery-card' ) . filter ( { hasText : 'DSC_1960.JPG' } ) . first ( ) ) . toBeVisible ( ) ;
await verifySearchLogs ( search . id , {
expectedMatchCount : EXPECTED _MATCH _COUNT ,
expectedSelfieName : 'DSC_1960.JPG'
} ) ;
} ) ;
test ( 'shows the unsupported-race message when the current race has no PKL data and lets the user go back' , async ( { page } ) => {
await launchFromSimulator ( page , {
raceId : '404' ,
raceSlug : 'corsa-di-livorno' ,
raceName : 'Corsa di Livorno' ,
raceFolder : 'LIVORNO'
} ) ;
await expect ( page . locator ( '.faceai-feedback' ) ) . toContainText ( 'FaceAI non è disponibile per questa gara.' ) ;
await expect ( page . locator ( 'input[type="file"]' ) ) . toBeDisabled ( ) ;
await expect ( page . getByRole ( 'button' , { name : 'Scegli immagine' } ) ) . toBeDisabled ( ) ;
await page . waitForTimeout ( 2000 ) ;
await expect ( page ) . toHaveURL ( FACEAI _HOME _URL _RE ) ;
2026-04-19 12:10:18 +02:00
await page . getByRole ( 'button' , { name : 'Torna alla pagina gara' } ) . click ( ) ;
2026-04-12 19:31:12 +02:00
await expect ( page ) . toHaveURL ( buildLegacySimulatorReturnMatcher ( '404' ) ) ;
} ) ;
test ( 'shows a localized invalid-race error when session race data points to a missing folder' , async ( { page } ) => {
const consoleErrors = [ ] ;
page . on ( 'console' , ( message ) => {
if ( message . type ( ) === 'error' ) {
consoleErrors . push ( message . text ( ) ) ;
}
} ) ;
const simulatorUrl = buildSimulatorUrl ( {
raceId : '405' ,
lang : 'en' ,
raceSlug : 'ghost-race' ,
raceName : 'Ghost Race' ,
raceFolder : 'THIS RACE DOES NOT EXIST'
} ) ;
await page . goto ( simulatorUrl , { waitUntil : 'domcontentloaded' } ) ;
await expect ( page . locator ( '#faceaiLaunchButton' ) ) . toBeVisible ( ) ;
await page . locator ( '#faceaiLaunchButton' ) . click ( ) ;
await page . waitForURL ( FACEAI _HOME _URL _RE , {
timeout : 60 * 1000
} ) ;
await expect ( page . getByRole ( 'heading' , { name : 'Find your photos with a selfie' } ) ) . toBeVisible ( ) ;
await expect ( page . locator ( '.faceai-feedback' ) ) . toContainText ( 'The race data received for this session is invalid. Go back to the race page and reopen Face ID from the correct race.' ) ;
await expect ( page . locator ( 'input[type="file"]' ) ) . toBeDisabled ( ) ;
await expect ( page . getByRole ( 'button' , { name : 'Choose image' } ) ) . toBeDisabled ( ) ;
await expect ( page . getByRole ( 'button' , { name : 'Start Face ID search' } ) ) . toHaveCount ( 0 ) ;
2026-04-19 12:10:18 +02:00
await expect ( page . getByRole ( 'button' , { name : 'Back to the race page' } ) ) . toBeVisible ( ) ;
2026-04-12 19:31:12 +02:00
await expect . poll ( ( ) => {
return consoleErrors . find ( ( entry ) => entry . includes ( '[FaceAI] Invalid race data:' ) ) || null ;
} ) . toContain ( 'RACE_DIRECTORY_NOT_FOUND' ) ;
await expect . poll ( ( ) => {
return consoleErrors . find ( ( entry ) => entry . includes ( '[FaceAI] Invalid race data:' ) ) || null ;
} ) . toContain ( 'THIS RACE DOES NOT EXIST' ) ;
2026-04-19 12:10:18 +02:00
await page . getByRole ( 'button' , { name : 'Back to the race page' } ) . click ( ) ;
2026-04-12 19:31:12 +02:00
await expect ( page ) . toHaveURL ( buildLegacySimulatorReturnMatcher ( '405' ) ) ;
} ) ;
test ( 'rejects a not-logged-in user after clicking the Face ID button and sends them back to the original race page' , async ( { page } ) => {
const simulatorUrl = buildSimulatorUrl ( {
raceId : '202' ,
raceSlug : 'mezza-di-pisa' ,
raceName : 'Mezza di Pisa' ,
raceFolder : 'PISA'
} ) ;
await page . goto ( simulatorUrl , { waitUntil : 'domcontentloaded' } ) ;
await expect ( page . locator ( '#faceaiLaunchButton' ) ) . toBeVisible ( ) ;
await page . evaluate ( ( ) => {
if ( window . faceAiSimulator ) {
delete window . faceAiSimulator . devUserId ;
delete window . faceAiSimulator . devDisplayName ;
delete window . faceAiSimulator . devEmail ;
delete window . faceAiSimulator . devMembershipStatus ;
}
} ) ;
await page . locator ( '#faceaiLaunchButton' ) . click ( ) ;
await expect ( page ) . toHaveURL ( buildLegacySimulatorReturnMatcher ( '202' ) ) ;
await expect ( page ) . not . toHaveURL ( FACEAI _HOME _URL _RE ) ;
await expect ( page . locator ( '#faceAiErrorModal' ) ) . toBeVisible ( ) ;
await expect ( page . locator ( '#faceAiErrorModalLabel' ) ) . toContainText ( 'Face ID non disponibile' ) ;
await expect ( page . locator ( '#faceAiErrorModalMessage' ) ) . toContainText ( 'Il servizio Face ID non e al momento disponibile. Riprova piu tardi.' ) ;
} ) ;
test ( 'shows the no-face message and allows the user to return to the race page' , async ( { page } ) => {
await launchFromSimulator ( page , {
raceId : '202' ,
raceSlug : 'mezza-di-pisa' ,
raceName : 'Mezza di Pisa' ,
raceFolder : 'PISA'
} ) ;
const search = await startSearch ( page , 'DSC_1994.JPG' ) ;
await waitForSearchCondition ( page , search . id , ( payload ) => {
return payload . status === 'completed' && payload . completionCode === 'NO_FACES_FOUND' ;
} , 2 * 60 * 1000 ) ;
await expect ( page . locator ( '.faceai-feedback' ) ) . toContainText ( 'Nessun volto rilevato nella foto caricata' ) ;
await page . waitForTimeout ( 2000 ) ;
await expect ( page ) . toHaveURL ( FACEAI _HOME _URL _RE ) ;
await verifySearchLogs ( search . id , {
expectedMatchCount : 0 ,
expectedSelfieName : 'DSC_1994.JPG'
} ) ;
2026-04-19 12:10:18 +02:00
await page . getByRole ( 'button' , { name : 'Torna alla pagina gara' } ) . click ( ) ;
2026-04-12 19:31:12 +02:00
await expect ( page ) . toHaveURL ( buildLegacySimulatorReturnMatcher ( '202' ) ) ;
} ) ;
test ( 'lets the user retry with a valid photo after a no-face upload and then returns to the filtered legacy result' , async ( { page } ) => {
await launchFromSimulator ( page , {
raceId : '202' ,
raceSlug : 'mezza-di-pisa' ,
raceName : 'Mezza di Pisa' ,
raceFolder : 'PISA'
} ) ;
const noFaceSearch = await startSearch ( page , 'DSC_1994.JPG' ) ;
await waitForSearchCondition ( page , noFaceSearch . id , ( payload ) => {
return payload . status === 'completed' && payload . completionCode === 'NO_FACES_FOUND' ;
} , 2 * 60 * 1000 ) ;
await expect ( page . locator ( '.faceai-feedback' ) ) . toContainText ( 'Nessun volto rilevato nella foto caricata' ) ;
await expect ( page . locator ( 'input[type="file"]' ) ) . toBeEnabled ( ) ;
const retrySearch = await startSearch ( page , 'DSC_1960.JPG' ) ;
await waitForLegacyResult ( page , EXPECTED _MATCH _COUNT ) ;
await verifySearchLogs ( noFaceSearch . id , {
expectedMatchCount : 0 ,
expectedSelfieName : 'DSC_1994.JPG'
} ) ;
await verifySearchLogs ( retrySearch . id , {
expectedMatchCount : EXPECTED _MATCH _COUNT ,
expectedSelfieName : 'DSC_1960.JPG'
} ) ;
} ) ;
test ( 'redirects direct-entry users without FaceAI session data back to the legacy site' , async ( { browser } ) => {
const context = await browser . newContext ( ) ;
const page = await context . newPage ( ) ;
try {
await page . goto ( ` ${ FACEAI _BASE _URL } / ` , { waitUntil : 'domcontentloaded' } ) ;
await page . waitForURL ( LEGACY _HOME _URL _RE , { timeout : 30 * 1000 } ) ;
} finally {
await context . close ( ) ;
}
} ) ;
test ( 'allows two users to process different photos at the same time' , async ( { browser } ) => {
const contexts = [ await browser . newContext ( ) , await browser . newContext ( ) ] ;
const pages = await Promise . all ( contexts . map ( ( context ) => context . newPage ( ) ) ) ;
try {
await Promise . all ( [
enterViaHandoff ( pages [ 0 ] , { userId : 'concurrency-user-1' } ) ,
enterViaHandoff ( pages [ 1 ] , { userId : 'concurrency-user-2' } )
] ) ;
const [ searchOne , searchTwo ] = await Promise . all ( [
startSearch ( pages [ 0 ] , 'DSC_1960.JPG' ) ,
startSearch ( pages [ 1 ] , 'DSC_1987.JPG' )
] ) ;
await Promise . all ( [
waitForLegacyResult ( pages [ 0 ] ) ,
waitForLegacyResult ( pages [ 1 ] )
] ) ;
await Promise . all ( [
verifySearchLogs ( searchOne . id , { expectedSelfieName : 'DSC_1960.JPG' } ) ,
verifySearchLogs ( searchTwo . id , { expectedSelfieName : 'DSC_1987.JPG' } )
] ) ;
} finally {
await closeContexts ( contexts ) ;
}
} ) ;
test ( 'queues the third user until a worker is free and then completes all three searches normally' , async ( { browser } ) => {
const contexts = [ await browser . newContext ( ) , await browser . newContext ( ) , await browser . newContext ( ) ] ;
const pages = await Promise . all ( contexts . map ( ( context ) => context . newPage ( ) ) ) ;
try {
await Promise . all ( [
enterViaHandoff ( pages [ 0 ] , { userId : 'queue-user-1' } ) ,
enterViaHandoff ( pages [ 1 ] , { userId : 'queue-user-2' } ) ,
enterViaHandoff ( pages [ 2 ] , { userId : 'queue-user-3' } )
] ) ;
const [ searchOne , searchTwo , searchThree ] = await Promise . all ( [
startSearch ( pages [ 0 ] , 'DSC_1960.JPG' ) ,
startSearch ( pages [ 1 ] , 'DSC_1987.JPG' ) ,
startSearch ( pages [ 2 ] , 'DSC_2058.JPG' )
] ) ;
const searchSessions = [
{ page : pages [ 0 ] , searchId : searchOne . id } ,
{ page : pages [ 1 ] , searchId : searchTwo . id } ,
{ page : pages [ 2 ] , searchId : searchThree . id }
] ;
let queuedSearch = null ;
const deadline = Date . now ( ) + 30 * 1000 ;
while ( Date . now ( ) < deadline && ! queuedSearch ) {
const statuses = await Promise . all ( searchSessions . map ( async ( session ) => {
const payload = await fetchSearchStatus ( session . page , session . searchId ) ;
return {
... session ,
search : payload . body
} ;
} ) ) ;
const processingCount = statuses . filter ( ( item ) => item . search ? . status === 'processing' ) . length ;
queuedSearch = processingCount >= 2
? statuses . find ( ( item ) => item . search ? . status === 'queued' ) || null
: null ;
if ( ! queuedSearch ) {
await pages [ 0 ] . waitForTimeout ( 250 ) ;
}
}
expect ( queuedSearch , 'one search should remain queued while two worker slots are busy' ) . toBeTruthy ( ) ;
await waitForSearchCondition ( queuedSearch . page , queuedSearch . searchId , ( payload ) => {
return payload . status === 'processing' || payload . status === 'completed' ;
} , 2 * 60 * 1000 ) ;
await Promise . all ( pages . map ( ( page ) => waitForLegacyResult ( page ) ) ) ;
await Promise . all ( [
verifySearchLogs ( searchOne . id , { expectedSelfieName : 'DSC_1960.JPG' } ) ,
verifySearchLogs ( searchTwo . id , { expectedSelfieName : 'DSC_1987.JPG' } ) ,
verifySearchLogs ( searchThree . id , { expectedSelfieName : 'DSC_2058.JPG' } )
] ) ;
} finally {
await closeContexts ( contexts ) ;
}
} ) ;