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); await page.getByRole('button', { name: 'Torna alla pagina gara' }).click(); 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); await expect(page.getByRole('button', { name: 'Back to the race page' })).toBeVisible(); 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'); await page.getByRole('button', { name: 'Back to the race page' }).click(); 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' }); await page.getByRole('button', { name: 'Torna alla pagina gara' }).click(); 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); } });