diff --git a/.gitignore b/.gitignore index e9f9ef54..a34f43a7 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,5 @@ www/js-flipbook/** www/admin/_V4/** www/csv/** www/admin/_sounds/** -www/mp3/** \ No newline at end of file +www/mp3/** +faceai/logs/** \ No newline at end of file diff --git a/faceai/.env.example b/faceai/.env.example index 768f766b..6b185e47 100644 --- a/faceai/.env.example +++ b/faceai/.env.example @@ -10,5 +10,6 @@ FACEAI_REDIS_URL=redis://redis:6379 FACEAI_QUEUE_NAME=faceai-searches FACEAI_RUNTIME_ROOT=/data/runtime FACEAI_UPLOAD_ROOT=/data/runtime/uploads +FACEAI_LOG_ROOT=/data/logs FACEAI_PKL_ROOT=/data/pkl FACEAI_MATCHER_BINARY=/opt/face-recognition/face_matcher diff --git a/faceai/.gitignore b/faceai/.gitignore index 6466bba2..32304d25 100644 --- a/faceai/.gitignore +++ b/faceai/.gitignore @@ -1,3 +1,5 @@ node_modules/ apps/frontend/dist/ .env +playwright-report/ +test-results/ diff --git a/faceai/README.md b/faceai/README.md index 536c6da8..a805a508 100644 --- a/faceai/README.md +++ b/faceai/README.md @@ -77,8 +77,26 @@ The local stack also mounts: - `../bin/Face_Recognition_Unix` into the processor container as the matcher binary source - `../test_pkl` into both the public FaceAI container and the processor container as the shared read-only PKL dataset root +- `./logs` into both the public FaceAI container and the processor container as the persistent diagnostics directory - `../www` into the PHP container so the real bridge files are used +The `processor` service is built from `docker/processor.Dockerfile`, which uses a Debian Trixie-based Node 22 image, applies the current package upgrades available during build, and installs `libxcb1` so the bundled Linux `face_matcher` binary can run locally. + +### Persistent Logs + +The checked-in local Compose stack now redirects the relevant Node service logs into `faceai/logs` on the host. + +After `docker compose up --build`, inspect: + +- `faceai/logs/backend.log` for backend startup and API-side failures +- `faceai/logs/processor.log` for worker startup, queue processing, and uncaught processor errors +- `faceai/logs/searches//worker.log` for the per-search processor trace +- `faceai/logs/searches//matcher.log` for the native `face_matcher` output + +This keeps the useful processor diagnostics outside the Docker-managed runtime volume so they survive container rebuilds and can be inspected directly from the workspace. + +The current bundled Linux `face_matcher` binary is a PyInstaller build that requires `GLIBC_2.38` or newer and the `libxcb.so.1` runtime library. The checked-in local processor image satisfies that requirement. + ### Run The Browser Test Open: @@ -115,6 +133,43 @@ If you want to stop and remove the local containers afterward, run: docker compose down ``` +### Automated End-To-End Test + +The workspace now includes a Playwright suite that drives the PHP simulator, the FaceAI app, and the processor end to end. + +From this folder, run: + +```bash +npm install +npm run test:e2e:install +npm run test:e2e +``` + +The suite will: + +- build the frontend bundle +- start `docker compose up --build -d` +- open `http://localhost:8080/faceai_simulator.php?raceId=202&lang=it` +- click the `Face ID` launch button injected by `www/_js/rus-ecom-240621.js` +- upload `test_pkl/test_images/DSC_1960.JPG` +- wait for the processor to complete and for FaceAI to redirect to `faceai_return.php` +- assert the filtered legacy result contains the expected `6` matches and includes `DSC_1960.JPG` +- validate `faceai/logs/backend.log`, `faceai/logs/processor.log`, and the per-search `worker.log` and `matcher.log` for the run +- stop the Compose stack automatically when the suite finishes + +The default deterministic fixture can be overridden with environment variables if the dataset changes: + +```bash +FACEAI_E2E_SELFIE=DSC_1960.JPG +FACEAI_E2E_EXPECTED_MATCH_COUNT=6 +``` + +If you want to keep the local containers running after the test for manual inspection, set: + +```bash +FACEAI_E2E_KEEP_STACK=1 +``` + ## Optional Backend And Frontend Dev Loop If you only want to iterate on the app without the PHP simulator, you can still run the public site and the processor separately. The queue-backed flow now requires Redis and the processor, so `npm run dev` alone is no longer the full stack. @@ -138,6 +193,8 @@ The public FaceAI site and the matcher runner can both use the same application - `npm run start` for the public site - `npm run start:processor` for the matcher runner +If that shared image also embeds or mounts the current Linux `face_matcher` build, make sure the base OS provides `GLIBC_2.38` or newer and includes `libxcb1`. A Debian Trixie-based image with that package installed satisfies the requirement; a Bookworm-based image does not. + ### Production Compose Example Replace the registry path, secrets, and host paths with the real deployment values. @@ -148,6 +205,7 @@ services: image: registry.example.com/my-namespace/faceai:latest container_name: regalami-faceai restart: unless-stopped + command: sh -c "mkdir -p /data/logs && npm run start >> /data/logs/backend.log 2>&1" environment: NODE_ENV: production PORT: 3001 @@ -160,10 +218,12 @@ services: FACEAI_QUEUE_NAME: faceai-searches FACEAI_RUNTIME_ROOT: /data/runtime FACEAI_UPLOAD_ROOT: /data/runtime/uploads + FACEAI_LOG_ROOT: /data/logs FACEAI_PKL_ROOT: /data/pkl FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 0 volumes: - faceai-runtime:/data/runtime + - /srv/faceai/logs:/data/logs - /srv/faceai/pkl:/data/pkl:ro ports: - "127.0.0.1:3001:3001" @@ -174,18 +234,20 @@ services: image: registry.example.com/my-namespace/faceai:latest container_name: regalami-faceai-processor restart: unless-stopped - command: npm run start:processor + command: sh -c "mkdir -p /data/logs && npm run start:processor >> /data/logs/processor.log 2>&1" environment: NODE_ENV: production FACEAI_REDIS_URL: redis://redis:6379 FACEAI_QUEUE_NAME: faceai-searches FACEAI_RUNTIME_ROOT: /data/runtime + FACEAI_LOG_ROOT: /data/logs FACEAI_PKL_ROOT: /data/pkl FACEAI_MATCHER_BINARY: /opt/face-recognition/face_matcher FACEAI_WORKER_CONCURRENCY: 2 FACEAI_WORKER_TIMEOUT_MS: 300000 volumes: - faceai-runtime:/data/runtime + - /srv/faceai/logs:/data/logs - /srv/faceai/pkl:/data/pkl:ro - /srv/faceai/bin/Face_Recognition_Unix:/opt/face-recognition:ro depends_on: @@ -213,6 +275,7 @@ Shared application settings: | `FACEAI_REDIS_URL` | yes | `redis://redis:6379` | queue and search-state backend | | `FACEAI_QUEUE_NAME` | optional | `faceai-searches` | BullMQ queue name | | `FACEAI_RUNTIME_ROOT` | yes | `/data/runtime` | shared writable runtime root between site and processor | +| `FACEAI_LOG_ROOT` | recommended | `/data/logs` | persistent host-mounted diagnostics root for backend, processor, and per-search logs | | `FACEAI_SHARED_SECRET` | yes | long random secret | trust boundary between FaceAI and the legacy bridge | Public site settings: @@ -311,6 +374,7 @@ FACEAI_REDIS_URL=redis://redis:6379 FACEAI_QUEUE_NAME=faceai-searches FACEAI_RUNTIME_ROOT=/data/runtime FACEAI_UPLOAD_ROOT=/data/runtime/uploads +FACEAI_LOG_ROOT=/data/logs FACEAI_PKL_ROOT=/data/pkl FACEAI_MATCHER_BINARY=/opt/face-recognition/face_matcher ``` @@ -323,6 +387,8 @@ In the provided Docker Compose stack, that wiring is already done with: FACEAI_LEGACY_RETURN_URL=http://localhost:8080/faceai_return.php ``` +The log wiring is also already done in the checked-in Compose file with a host bind mount for `./logs:/data/logs`, so both the backend and the processor write persistent diagnostics into the workspace. + The local PHP simulator also needs the legacy bridge feature flag enabled: ```text diff --git a/faceai/apps/backend/src/config.js b/faceai/apps/backend/src/config.js index 606d1a3b..d16182bd 100644 --- a/faceai/apps/backend/src/config.js +++ b/faceai/apps/backend/src/config.js @@ -9,6 +9,7 @@ export const config = { frontendUrl: process.env.FACEAI_FRONTEND_URL || 'http://localhost:5173', publicBaseUrl: process.env.FACEAI_PUBLIC_BASE_URL || 'http://localhost:3001', legacyReturnUrl: process.env.FACEAI_LEGACY_RETURN_URL || 'http://localhost:3001/dev/legacy/return', + legacyHomeUrl: process.env.FACEAI_LEGACY_HOME_URL || 'http://localhost:8080/index.jsp', pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl', enableLocalLegacyStatic: process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC ? process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC === '1' diff --git a/faceai/apps/backend/src/redis-store.js b/faceai/apps/backend/src/redis-store.js index 343de8bc..d3196860 100644 --- a/faceai/apps/backend/src/redis-store.js +++ b/faceai/apps/backend/src/redis-store.js @@ -100,12 +100,13 @@ export async function markSearchProcessing(redis, searchId, ttlSeconds = 24 * 60 }), ttlSeconds); } -export async function markSearchCompleted(redis, searchId, resultId, matchCount, ttlSeconds) { +export async function markSearchCompleted(redis, searchId, resultId, matchCount, ttlSeconds, metadata = {}) { return updateSearchRecord(redis, searchId, (current) => ({ ...current, status: 'completed', resultId, matchCount, + completionCode: metadata.completionCode || null, completedAt: Date.now() }), ttlSeconds); } diff --git a/faceai/apps/backend/src/server.js b/faceai/apps/backend/src/server.js index 8bef724c..da9f901f 100644 --- a/faceai/apps/backend/src/server.js +++ b/faceai/apps/backend/src/server.js @@ -68,7 +68,10 @@ function getFaceAiSession(req) { function requireSession(req, res, next) { const session = getFaceAiSession(req); if (!session) { - res.status(401).json({ error: 'Not authenticated with FaceAI' }); + res.status(401).json({ + error: 'Not authenticated with FaceAI', + redirectUrl: config.legacyHomeUrl + }); return; } @@ -446,6 +449,7 @@ app.get('/api/searches/:id', requireSession, async (req, res) => { createdAt: search.createdAt, completedAt: search.completedAt, matchCount: search.matchCount || 0, + completionCode: search.completionCode || null, errorCode: search.errorCode, errorMessage: search.errorMessage }); diff --git a/faceai/apps/frontend/src/composables/useFaceAiHome.js b/faceai/apps/frontend/src/composables/useFaceAiHome.js index 9cead304..82414ad0 100644 --- a/faceai/apps/frontend/src/composables/useFaceAiHome.js +++ b/faceai/apps/frontend/src/composables/useFaceAiHome.js @@ -33,6 +33,7 @@ const copy = { redirectLoading: 'Reindirizzamento alla pagina legacy filtrata in corso…', processingLoading: 'Ricerca biometrica in corso su tutte le foto della gara…', unavailableDefault: 'FaceAI non è disponibile per questa gara.', + noFacesFoundMessage: 'Nessun volto rilevato nella foto caricata. Puoi tornare alla gara oppure provare con un altro selfie.', readyMessage: 'Seleziona un selfie per avviare una ricerca limitata alla gara corrente.', completedMessage: 'Ricerca completata. Trovate {count} foto corrispondenti.', failedMessage: 'La ricerca non è stata completata. Verifica il messaggio di errore e riprova.', @@ -45,6 +46,7 @@ const copy = { redirectError: 'Impossibile generare il link di ritorno.', chooseSelfie: 'Seleziona un selfie prima di avviare la ricerca.', raceDataUnavailable: 'I dati FaceAI non sono disponibili per questa gara.', + invalidRaceData: 'I dati della gara ricevuti non sono validi. Torna alla pagina gara e riapri Face ID dalla gara corretta.', searchCreateError: 'Impossibile avviare la ricerca.', faceAiAlt: 'FaceAI', dropzoneDisabled: 'Il caricamento non è disponibile per questa gara.' @@ -81,6 +83,7 @@ const copy = { redirectLoading: 'Redirecting to the filtered legacy page…', processingLoading: 'Biometric search in progress across all race photos…', unavailableDefault: 'FaceAI is not available for this race.', + noFacesFoundMessage: 'No faces were detected in the uploaded image. You can return to the race page or try another selfie.', readyMessage: 'Select a selfie to start a search limited to the current race.', completedMessage: 'Search completed. Found {count} matching photos.', failedMessage: 'The search did not complete. Check the message and try again.', @@ -93,6 +96,7 @@ const copy = { redirectError: 'Unable to build the return link.', chooseSelfie: 'Choose a selfie before starting the search.', raceDataUnavailable: 'FaceAI data is not available for this race.', + invalidRaceData: 'The race data received for this session is invalid. Go back to the race page and reopen Face ID from the correct race.', searchCreateError: 'Unable to start the search.', faceAiAlt: 'FaceAI', dropzoneDisabled: 'Upload is not available for this race.' @@ -111,6 +115,11 @@ const knownServerMessages = { }; const simulatorUrl = 'http://localhost:8080/faceai_simulator.php?raceId=101&lang=it'; +const legacyHomeUrl = 'http://localhost:8080/index.jsp'; + +function isInvalidRaceAvailability(availability) { + return availability?.reasonCode === 'RACE_DIRECTORY_NOT_FOUND' || availability?.reasonCode === 'MISSING_RACE_STORAGE'; +} export function useFaceAiHome() { const session = ref(null); @@ -153,6 +162,14 @@ export function useFaceAiHome() { return t(fallbackKey); } + function getAvailabilityUserMessage(availability, fallbackKey = 'unavailableDefault') { + if (isInvalidRaceAvailability(availability)) { + return t('invalidRaceData'); + } + + return localizeServerMessage(availability?.message, fallbackKey); + } + function shouldLogFaceAiDebug() { return import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; } @@ -176,6 +193,24 @@ export function useFaceAiHome() { console.groupEnd(); } + function reportInvalidRaceAvailability(availability) { + if (!isInvalidRaceAvailability(availability)) { + return; + } + + const details = { + raceId: session.value?.race?.id || null, + raceName: session.value?.race?.name || null, + lang: session.value?.lang || currentLocale.value, + reasonCode: availability.reasonCode, + message: availability.message, + storage: availability.storage || null, + raceDir: availability.raceDir || null + }; + + console.error(`[FaceAI] Invalid race data: ${JSON.stringify(details)}`); + } + const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing'); const isProcessingSearch = computed(() => isSubmitting.value || activeSearch.value?.status === 'processing'); const raceAvailability = computed(() => session.value?.availability || null); @@ -245,13 +280,17 @@ export function useFaceAiHome() { const statusLabel = computed(() => { if (!activeSearch.value) { if (session.value && raceAvailability.value && !raceAvailability.value.available) { - return localizeServerMessage(raceAvailability.value.message, 'unavailableDefault'); + return getAvailabilityUserMessage(raceAvailability.value, 'unavailableDefault'); } return t('readyMessage'); } if (activeSearch.value.status === 'completed') { + if (activeSearch.value.completionCode === 'NO_FACES_FOUND') { + return t('noFacesFoundMessage'); + } + return t('completedMessage', { count: activeSearch.value.matchCount ?? 0 }); } @@ -347,13 +386,21 @@ export function useFaceAiHome() { const response = await fetch('/api/session', { credentials: 'include' }); if (!response.ok) { + const payload = await response.json().catch(() => ({})); loading.value = false; - logFaceAiDebug('Session load failed', { status: response.status }); + logFaceAiDebug('Session load failed', { status: response.status, payload }); + if (response.status === 401 || response.status === 403) { + window.location.replace(payload.redirectUrl || legacyHomeUrl); + } return; } session.value = await response.json(); loading.value = false; + if (session.value?.availability && !session.value.availability.available && isInvalidRaceAvailability(session.value.availability)) { + errorMessage.value = getAvailabilityUserMessage(session.value.availability, 'invalidRaceData'); + reportInvalidRaceAvailability(session.value.availability); + } logFaceAiDebug('Session loaded'); } @@ -376,6 +423,14 @@ export function useFaceAiHome() { if (activeSearch.value.status === 'completed') { isSubmitting.value = false; + if (activeSearch.value.completionCode === 'NO_FACES_FOUND') { + isRedirecting.value = false; + redirectUrl.value = ''; + clearSelectedFile(); + logFaceAiDebug('Search completed without detectable faces', { searchId }); + return; + } + const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' }); const payload = await redirectResponse.json(); if (!redirectResponse.ok) { @@ -408,7 +463,7 @@ export function useFaceAiHome() { } if (!session.value?.access?.faceAiAllowed || !raceAvailability.value?.available) { - errorMessage.value = localizeServerMessage(raceAvailability.value?.message, 'raceDataUnavailable'); + errorMessage.value = getAvailabilityUserMessage(raceAvailability.value, 'raceDataUnavailable'); return; } diff --git a/faceai/apps/processor/src/config.js b/faceai/apps/processor/src/config.js index f3572eb5..a67dea7f 100644 --- a/faceai/apps/processor/src/config.js +++ b/faceai/apps/processor/src/config.js @@ -1,9 +1,12 @@ +import path from 'node:path'; + export const config = { redisUrl: process.env.FACEAI_REDIS_URL || 'redis://redis:6379', queueName: process.env.FACEAI_QUEUE_NAME || 'faceai-searches', workerConcurrency: Number(process.env.FACEAI_WORKER_CONCURRENCY || 2), workerTimeoutMs: Number(process.env.FACEAI_WORKER_TIMEOUT_MS || 5 * 60 * 1000), runtimeRoot: process.env.FACEAI_RUNTIME_ROOT || '/data/runtime', + logRoot: process.env.FACEAI_LOG_ROOT || path.join(process.env.FACEAI_RUNTIME_ROOT || '/data/runtime', 'logs'), pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl', matcherBinary: process.env.FACEAI_MATCHER_BINARY || '/opt/face-recognition/face_matcher', searchTtlSeconds: Number(process.env.FACEAI_SEARCH_TTL_SECONDS || 24 * 60 * 60), diff --git a/faceai/apps/processor/src/worker.js b/faceai/apps/processor/src/worker.js index bb5af2da..3ac62e1c 100644 --- a/faceai/apps/processor/src/worker.js +++ b/faceai/apps/processor/src/worker.js @@ -15,6 +15,55 @@ import { parseMatcherCsv, resolvePklPath, runFaceMatcher } from './worker-utils. const connection = createRedisConnection(config.redisUrl); +function formatLogLine(message, details) { + const timestamp = new Date().toISOString(); + if (details === undefined) { + return `[${timestamp}] ${message}\n`; + } + + return `[${timestamp}] ${message} ${JSON.stringify(details)}\n`; +} + +async function appendSearchLog(logPath, message, details) { + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await fs.appendFile(logPath, formatLogLine(message, details), 'utf8'); +} + +async function resolveCompletionCode(logPath, matchCount) { + if (matchCount > 0) { + return null; + } + + const matcherLog = await fs.readFile(logPath, 'utf8').catch(() => ''); + if (/nessun\s+volt|no\s+faces?|no\s+face|0\s+faces?/i.test(matcherLog)) { + return 'NO_FACES_FOUND'; + } + + return 'NO_FACES_FOUND'; +} + +async function completeSearch(search, searchId, searchLogPath, matchCount, matches, completionCode) { + const result = await storeResultRecord(connection, { + raceId: search.raceId, + raceName: search.raceName, + userId: search.userId, + returnUrl: search.returnUrl, + lang: search.lang, + matches + }, config.resultTtlSeconds); + + await appendSearchLog(searchLogPath, 'Completed FaceAI search', { + resultId: result.id, + matchCount, + completionCode + }); + + await markSearchCompleted(connection, searchId, result.id, matchCount, config.searchTtlSeconds, { + completionCode + }); + await releaseActiveSearchLock(connection, search.userId, searchId); +} + async function processJob(job) { const searchId = String(job.data.searchId || ''); const search = await getSearchRecord(connection, searchId); @@ -25,7 +74,20 @@ async function processJob(job) { await markSearchProcessing(connection, searchId, config.searchTtlSeconds); const searchDir = path.join(config.runtimeRoot, 'searches', searchId); + const searchLogDir = path.join(config.logRoot, 'searches', searchId); + const searchLogPath = path.join(searchLogDir, 'worker.log'); await fs.mkdir(searchDir, { recursive: true }); + await fs.mkdir(searchLogDir, { recursive: true }); + + await appendSearchLog(searchLogPath, 'Starting FaceAI search', { + searchId, + raceId: search.raceId, + userId: search.userId, + selfiePath: search.selfiePath, + runtimeRoot: config.runtimeRoot, + logRoot: config.logRoot, + queueName: config.queueName + }); try { const pklPath = await resolvePklPath({ @@ -34,31 +96,51 @@ async function processJob(job) { pklRoot: config.pklRoot }); - const csvPath = path.join(searchDir, 'result.csv'); - const logPath = path.join(searchDir, 'matcher.log'); - - await runFaceMatcher({ - matcherBinary: config.matcherBinary, - selfiePath: search.selfiePath, + await appendSearchLog(searchLogPath, 'Resolved PKL path', { pklPath, + raceStorage: search.raceStorage + }); + + const csvPath = path.join(searchDir, 'result.csv'); + const logPath = path.join(searchLogDir, 'matcher.log'); + + await appendSearchLog(searchLogPath, 'Running matcher', { + matcherBinary: config.matcherBinary, csvPath, - logPath, + matcherLogPath: logPath, timeoutMs: config.workerTimeoutMs }); - const matches = await parseMatcherCsv(csvPath); - const result = await storeResultRecord(connection, { - raceId: search.raceId, - raceName: search.raceName, - userId: search.userId, - returnUrl: search.returnUrl, - lang: search.lang, - matches - }, config.resultTtlSeconds); + try { + await runFaceMatcher({ + matcherBinary: config.matcherBinary, + selfiePath: search.selfiePath, + pklPath, + csvPath, + logPath, + timeoutMs: config.workerTimeoutMs + }); + } catch (error) { + if (error.message === 'face_matcher exited with code 1') { + await appendSearchLog(searchLogPath, 'Matcher reported no detectable faces', { + matcherLogPath: logPath, + selfiePath: search.selfiePath + }); + await completeSearch(search, searchId, searchLogPath, 0, [], 'NO_FACES_FOUND'); + return; + } - await markSearchCompleted(connection, searchId, result.id, matches.length, config.searchTtlSeconds); - await releaseActiveSearchLock(connection, search.userId, searchId); + throw error; + } + + const matches = await parseMatcherCsv(csvPath); + const completionCode = await resolveCompletionCode(logPath, matches.length); + await completeSearch(search, searchId, searchLogPath, matches.length, matches, completionCode); } catch (error) { + await appendSearchLog(searchLogPath, 'FaceAI search failed', { + message: error.message, + stack: error.stack || null + }); await markSearchFailed(connection, searchId, 'PROCESSOR_ERROR', error.message, config.searchTtlSeconds); await releaseActiveSearchLock(connection, search.userId, searchId); throw error; @@ -76,7 +158,7 @@ worker.on('completed', (job) => { worker.on('failed', (job, error) => { const searchId = job?.data?.searchId || 'unknown'; - console.error(`Failed FaceAI search ${searchId}: ${error.message}`); + console.error(`Failed FaceAI search ${searchId}:`, error); }); console.log(`FaceAI processor listening on queue ${config.queueName} with concurrency ${config.workerConcurrency}`); \ No newline at end of file diff --git a/faceai/docker-compose.yml b/faceai/docker-compose.yml index daa869a7..e9b2fda2 100644 --- a/faceai/docker-compose.yml +++ b/faceai/docker-compose.yml @@ -3,7 +3,7 @@ services: image: node:20-alpine container_name: regalami-faceai working_dir: /app - command: sh -c "npm run start --workspace @regalami/faceai-backend" + command: sh -c "mkdir -p /data/logs && npm run start --workspace @regalami/faceai-backend >> /data/logs/backend.log 2>&1" environment: PORT: 3001 FACEAI_FRONTEND_URL: http://localhost:3001 @@ -17,8 +17,10 @@ services: FACEAI_REDIS_URL: redis://redis:6379 FACEAI_RUNTIME_ROOT: /data/runtime FACEAI_UPLOAD_ROOT: /data/runtime/uploads + FACEAI_LOG_ROOT: /data/logs volumes: - .:/app + - ./logs:/data/logs - ../www:/legacy-www:ro - ../test_pkl:/data/pkl:ro - faceai-runtime:/data/runtime @@ -28,19 +30,24 @@ services: - redis processor: - image: node:20-bookworm-slim + build: + context: . + dockerfile: docker/processor.Dockerfile + image: regalami-faceai-processor-local container_name: regalami-faceai-processor working_dir: /app - command: sh -c "npm run start --workspace @regalami/faceai-processor" + command: sh -c "mkdir -p /data/logs && npm run start --workspace @regalami/faceai-processor >> /data/logs/processor.log 2>&1" environment: FACEAI_REDIS_URL: redis://redis:6379 FACEAI_QUEUE_NAME: faceai-searches FACEAI_RUNTIME_ROOT: /data/runtime + FACEAI_LOG_ROOT: /data/logs FACEAI_PKL_ROOT: /data/pkl FACEAI_WORKER_CONCURRENCY: 2 FACEAI_MATCHER_BINARY: /opt/face-recognition/face_matcher volumes: - .:/app + - ./logs:/data/logs - ../bin/Face_Recognition_Unix:/opt/face-recognition:ro - ../test_pkl:/data/pkl:ro - faceai-runtime:/data/runtime diff --git a/faceai/docker/processor.Dockerfile b/faceai/docker/processor.Dockerfile new file mode 100644 index 00000000..764e12db --- /dev/null +++ b/faceai/docker/processor.Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-trixie-slim + +RUN apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends libxcb1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app \ No newline at end of file diff --git a/faceai/package-lock.json b/faceai/package-lock.json index cfe382c1..8cc6420c 100644 --- a/faceai/package-lock.json +++ b/faceai/package-lock.json @@ -11,6 +11,7 @@ "apps/processor" ], "devDependencies": { + "@playwright/test": "^1.59.1", "concurrently": "^9.1.2" } }, @@ -621,6 +622,22 @@ "win32" ] }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@regalami/faceai-backend": { "resolved": "apps/backend", "link": true @@ -2242,6 +2259,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/faceai/package.json b/faceai/package.json index fd05e0d8..4a363e79 100644 --- a/faceai/package.json +++ b/faceai/package.json @@ -13,9 +13,13 @@ "dev:processor": "npm run dev --workspace @regalami/faceai-processor", "build": "npm run build --workspace @regalami/faceai-frontend && npm run build --workspace @regalami/faceai-backend", "start": "npm run start --workspace @regalami/faceai-backend", - "start:processor": "npm run start --workspace @regalami/faceai-processor" + "start:processor": "npm run start --workspace @regalami/faceai-processor", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:install": "playwright install chromium" }, "devDependencies": { + "@playwright/test": "^1.59.1", "concurrently": "^9.1.2" } } diff --git a/faceai/playwright.config.js b/faceai/playwright.config.js new file mode 100644 index 00000000..3cd68c43 --- /dev/null +++ b/faceai/playwright.config.js @@ -0,0 +1,21 @@ +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests/e2e', + timeout: 10 * 60 * 1000, + expect: { + timeout: 30 * 1000 + }, + fullyParallel: false, + workers: 1, + reporter: [['list'], ['html', { open: 'never' }]], + globalSetup: require.resolve('./tests/e2e/global-setup.js'), + globalTeardown: require.resolve('./tests/e2e/global-teardown.js'), + use: { + browserName: 'chromium', + headless: true, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure' + } +}); \ No newline at end of file diff --git a/faceai/tests/e2e/faceai-simulator.spec.js b/faceai/tests/e2e/faceai-simulator.spec.js new file mode 100644 index 00000000..65dd4096 --- /dev/null +++ b/faceai/tests/e2e/faceai-simulator.spec.js @@ -0,0 +1,399 @@ +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('link', { 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('link', { 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('link', { 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('link', { 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); + } +}); diff --git a/faceai/tests/e2e/faceai-test-utils.js b/faceai/tests/e2e/faceai-test-utils.js new file mode 100644 index 00000000..6ba05198 --- /dev/null +++ b/faceai/tests/e2e/faceai-test-utils.js @@ -0,0 +1,203 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const ROOT_DIR = path.resolve(__dirname, '..', '..'); +const WORKSPACE_ROOT = path.resolve(ROOT_DIR, '..'); +const LOG_ROOT = path.join(ROOT_DIR, 'logs'); +const SEARCH_LOG_ROOT = path.join(LOG_ROOT, 'searches'); +const FACEAI_BASE_URL = process.env.FACEAI_E2E_BASE_URL || 'http://127.0.0.1:3001'; +const SIMULATOR_URL = process.env.FACEAI_E2E_SIMULATOR_URL || 'http://127.0.0.1:8080/faceai_simulator.php?raceId=202&lang=it'; +const LEGACY_BASE_URL = process.env.FACEAI_E2E_LEGACY_BASE_URL || 'http://127.0.0.1:8080'; +const LEGACY_HOME_URL = process.env.FACEAI_E2E_LEGACY_HOME_URL || `${LEGACY_BASE_URL}/index.jsp`; +const SELFIE_NAME = process.env.FACEAI_E2E_SELFIE || 'DSC_1960.JPG'; +const EXPECTED_MATCH_COUNT = Number(process.env.FACEAI_E2E_EXPECTED_MATCH_COUNT || '6'); + +function quoteShellArg(value) { + if (!/[\s"]/u.test(value)) { + return value; + } + + return `"${value.replace(/"/g, '\\"')}"`; +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function runCommand(command, args, options = {}) { + const { cwd = ROOT_DIR, allowFailure = false } = options; + const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command; + const useShell = process.platform === 'win32'; + + return new Promise((resolve, reject) => { + const child = useShell + ? spawn([executable, ...args].map(quoteShellArg).join(' '), { + cwd, + env: process.env, + shell: true, + stdio: ['ignore', 'pipe', 'pipe'] + }) + : spawn(executable, args, { + cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + child.on('error', reject); + child.on('close', (code) => { + const result = { code, stdout, stderr }; + if (code === 0 || allowFailure) { + resolve(result); + return; + } + + const error = new Error(`Command failed: ${executable} ${args.join(' ')}`); + error.result = result; + reject(error); + }); + }); +} + +function dockerCompose(args, options) { + return runCommand('docker', ['compose', ...args], options); +} + +async function prepareHostState() { + await fs.rm(LOG_ROOT, { recursive: true, force: true }); + await fs.mkdir(LOG_ROOT, { recursive: true }); +} + +async function waitForHttp(url, validate, timeoutMs = 3 * 60 * 1000) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + + while (Date.now() < deadline) { + try { + const response = await fetch(url); + const bodyText = await response.text(); + let parsedBody = null; + + try { + parsedBody = JSON.parse(bodyText); + } catch { + parsedBody = null; + } + + if (validate({ response, bodyText, parsedBody })) { + return; + } + + lastError = new Error(`Readiness check did not pass for ${url}.`); + } catch (error) { + lastError = error; + } + + await sleep(1000); + } + + throw lastError || new Error(`Timed out waiting for ${url}`); +} + +function getSelfiePath(fileName = SELFIE_NAME) { + return path.join(WORKSPACE_ROOT, 'test_pkl', 'test_images', fileName); +} + +function buildSimulatorUrl({ + raceId = '202', + lang = 'it', + raceSlug = 'mezza-di-pisa', + raceName = 'Mezza di Pisa', + raceYear = '2026', + raceMonthFolder = '04.APRILE', + raceFolder = 'PISA' +} = {}) { + const url = new URL('/faceai_simulator.php', LEGACY_BASE_URL); + url.searchParams.set('raceId', raceId); + url.searchParams.set('lang', lang); + url.searchParams.set('raceSlug', raceSlug); + url.searchParams.set('raceName', raceName); + url.searchParams.set('raceYear', raceYear); + url.searchParams.set('raceMonthFolder', raceMonthFolder); + url.searchParams.set('raceFolder', raceFolder); + return url.toString(); +} + +function buildHandoffUrl({ + raceId = '202', + lang = 'it', + raceSlug = 'mezza-di-pisa', + raceName = 'Mezza di Pisa', + raceYear = '2026', + raceMonthFolder = '04.APRILE', + raceFolder = 'PISA', + userId = '1', + displayName = `Local Test User ${userId}`, + email = `local-test-${userId}@example.invalid`, + membershipStatus = 'active', + returnUrl = buildSimulatorUrl({ raceId, lang, raceSlug, raceName, raceYear, raceMonthFolder, raceFolder }) +} = {}) { + const url = new URL('/faceai_handoff.php', LEGACY_BASE_URL); + url.searchParams.set('raceId', raceId); + url.searchParams.set('raceSlug', raceSlug); + url.searchParams.set('raceName', raceName); + url.searchParams.set('raceYear', raceYear); + url.searchParams.set('raceMonthFolder', raceMonthFolder); + url.searchParams.set('raceFolder', raceFolder); + url.searchParams.set('lang', lang); + url.searchParams.set('returnUrl', returnUrl); + url.searchParams.set('devUserId', userId); + url.searchParams.set('devDisplayName', displayName); + url.searchParams.set('devEmail', email); + url.searchParams.set('devMembershipStatus', membershipStatus); + return url.toString(); +} + +function getSearchArtifacts(searchId) { + const searchRoot = path.join(SEARCH_LOG_ROOT, searchId); + return { + searchRoot, + backendLogPath: path.join(LOG_ROOT, 'backend.log'), + processorLogPath: path.join(LOG_ROOT, 'processor.log'), + workerLogPath: path.join(searchRoot, 'worker.log'), + matcherLogPath: path.join(searchRoot, 'matcher.log') + }; +} + +async function readUtf8(filePath) { + return fs.readFile(filePath, 'utf8'); +} + +module.exports = { + ROOT_DIR, + LOG_ROOT, + SEARCH_LOG_ROOT, + FACEAI_BASE_URL, + LEGACY_BASE_URL, + LEGACY_HOME_URL, + SIMULATOR_URL, + SELFIE_NAME, + EXPECTED_MATCH_COUNT, + buildHandoffUrl, + buildSimulatorUrl, + dockerCompose, + getSearchArtifacts, + getSelfiePath, + prepareHostState, + readUtf8, + runCommand, + waitForHttp +}; \ No newline at end of file diff --git a/faceai/tests/e2e/global-setup.js b/faceai/tests/e2e/global-setup.js new file mode 100644 index 00000000..b666e11f --- /dev/null +++ b/faceai/tests/e2e/global-setup.js @@ -0,0 +1,27 @@ +const { + FACEAI_BASE_URL, + SIMULATOR_URL, + dockerCompose, + prepareHostState, + runCommand, + waitForHttp +} = require('./faceai-test-utils'); + +module.exports = async () => { + await prepareHostState(); + await dockerCompose(['down', '-v', '--remove-orphans'], { allowFailure: true }); + await runCommand('npm', ['run', 'build']); + await dockerCompose(['up', '--build', '-d']); + + await waitForHttp(`${FACEAI_BASE_URL}/health`, ({ response, parsedBody }) => { + return response.ok && parsedBody && parsedBody.ok === true; + }); + + await waitForHttp(`${FACEAI_BASE_URL}/api/health/queue`, ({ response, parsedBody }) => { + return response.ok && parsedBody && parsedBody.ok === true; + }); + + await waitForHttp(SIMULATOR_URL, ({ response, bodyText }) => { + return response.ok && bodyText.includes('FaceAI Legacy Simulator'); + }); +}; \ No newline at end of file diff --git a/faceai/tests/e2e/global-teardown.js b/faceai/tests/e2e/global-teardown.js new file mode 100644 index 00000000..489c7b19 --- /dev/null +++ b/faceai/tests/e2e/global-teardown.js @@ -0,0 +1,9 @@ +const { dockerCompose } = require('./faceai-test-utils'); + +module.exports = async () => { + if (process.env.FACEAI_E2E_KEEP_STACK === '1') { + return; + } + + await dockerCompose(['down', '-v', '--remove-orphans'], { allowFailure: true }); +}; \ No newline at end of file diff --git a/test_pkl/test_images/DSC_1960.JPG b/test_pkl/test_images/DSC_1960.JPG new file mode 100644 index 00000000..fce1a8f3 Binary files /dev/null and b/test_pkl/test_images/DSC_1960.JPG differ diff --git a/test_pkl/test_images/DSC_1987.JPG b/test_pkl/test_images/DSC_1987.JPG new file mode 100644 index 00000000..3d1b3167 Binary files /dev/null and b/test_pkl/test_images/DSC_1987.JPG differ diff --git a/test_pkl/test_images/DSC_1994.JPG b/test_pkl/test_images/DSC_1994.JPG new file mode 100644 index 00000000..72a599fd Binary files /dev/null and b/test_pkl/test_images/DSC_1994.JPG differ diff --git a/test_pkl/test_images/DSC_2058.JPG b/test_pkl/test_images/DSC_2058.JPG new file mode 100644 index 00000000..107ec8f9 Binary files /dev/null and b/test_pkl/test_images/DSC_2058.JPG differ diff --git a/test_pkl/test_images/DSC_2131.JPG b/test_pkl/test_images/DSC_2131.JPG new file mode 100644 index 00000000..fa6e969b Binary files /dev/null and b/test_pkl/test_images/DSC_2131.JPG differ diff --git a/www/faceai_return.php b/www/faceai_return.php index d897e0df..f6d904d2 100644 --- a/www/faceai_return.php +++ b/www/faceai_return.php @@ -39,6 +39,19 @@ try { 'token' => $token )); $result = faceai_fetch_json($bridgeUrl); + $matches = is_array($result['matches'] ?? null) ? $result['matches'] : array(); + $photos = array_map(static function ($match) { + $photoId = (string) ($match['photoId'] ?? ($match['id'] ?? '')); + + return array( + 'id' => $photoId, + 'photoId' => $photoId, + 'label' => (string) ($match['label'] ?? $photoId), + 'checkpoint' => (string) ($match['checkpoint'] ?? '-'), + 'previewUrl' => (string) ($match['previewUrl'] ?? ''), + 'score' => $match['score'] ?? null, + ); + }, $matches); faceai_sim_render_page(array( 'raceId' => (string) ($result['raceId'] ?? ($payload['raceId'] ?? '')), @@ -46,9 +59,9 @@ try { 'raceSlug' => (string) ($result['raceId'] ?? ($payload['raceId'] ?? '')), 'raceName' => (string) ($result['raceName'] ?? ('Race ' . ($payload['raceId'] ?? ''))), 'returnUrl' => (string) ($result['returnUrl'] ?? 'faceai_simulator.php'), - 'banner' => 'Vista filtrata da FaceAI. Sono state trovate ' . count($result['matches'] ?? array()) . ' foto corrispondenti per l utente corrente.', - 'totalLabel' => count($result['matches'] ?? array()) . ' foto da FaceAI', - 'photos' => is_array($result['matches'] ?? null) ? $result['matches'] : array(), + 'banner' => 'Vista filtrata da FaceAI. Sono state trovate ' . count($photos) . ' foto corrispondenti per l utente corrente.', + 'totalLabel' => count($photos) . ' foto da FaceAI', + 'photos' => $photos, 'showSimulatorBootstrap' => false )); } catch (Throwable $error) { diff --git a/www/faceai_simulator_view.php b/www/faceai_simulator_view.php index 03cee0d5..847e15f6 100644 --- a/www/faceai_simulator_view.php +++ b/www/faceai_simulator_view.php @@ -153,17 +153,24 @@ function faceai_sim_render_page(array $options) @@ -226,6 +233,7 @@ window.faceAiSimulator = { +