End to end tests
All checks were successful
Publish FaceAI Container / publish (push) Successful in 2m42s

This commit is contained in:
MaddoScientisto 2026-04-12 19:31:12 +02:00
commit 2218c9a84c
26 changed files with 1016 additions and 37 deletions

1
.gitignore vendored
View file

@ -67,3 +67,4 @@ www/admin/_V4/**
www/csv/**
www/admin/_sounds/**
www/mp3/**
faceai/logs/**

View file

@ -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

2
faceai/.gitignore vendored
View file

@ -1,3 +1,5 @@
node_modules/
apps/frontend/dist/
.env
playwright-report/
test-results/

View file

@ -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/<searchId>/worker.log` for the per-search processor trace
- `faceai/logs/searches/<searchId>/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

View file

@ -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'

View file

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

View file

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

View file

@ -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;
}

View file

@ -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),

View file

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

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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"
}
}

View file

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

View file

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

View file

@ -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
};

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

View file

@ -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 <strong>' . count($result['matches'] ?? array()) . '</strong> 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 <strong>' . count($photos) . '</strong> foto corrispondenti per l utente corrente.',
'totalLabel' => count($photos) . ' foto da FaceAI',
'photos' => $photos,
'showSimulatorBootstrap' => false
));
} catch (Throwable $error) {

View file

@ -153,17 +153,24 @@ function faceai_sim_render_page(array $options)
<div class="gallery-grid">
<?php foreach ($photos as $photo): ?>
<?php
$photoId = (string) ($photo['id'] ?? ($photo['photoId'] ?? ''));
$photoLabel = (string) ($photo['label'] ?? ($photoId !== '' ? $photoId : ($photo['thumb'] ?? 'Foto senza etichetta')));
$photoPreviewUrl = (string) ($photo['previewUrl'] ?? '');
$photoThumb = (string) ($photo['thumb'] ?? $photoId);
$photoCheckpoint = (string) ($photo['checkpoint'] ?? '-');
?>
<div class="gallery-card">
<div class="gallery-thumb">
<?php if (!empty($photo['previewUrl'])): ?>
<img src="<?php echo faceai_sim_html($photo['previewUrl']); ?>" alt="<?php echo faceai_sim_html($photo['label']); ?>">
<?php if ($photoPreviewUrl !== ''): ?>
<img src="<?php echo faceai_sim_html($photoPreviewUrl); ?>" alt="<?php echo faceai_sim_html($photoLabel); ?>">
<?php else: ?>
<?php echo faceai_sim_html($photo['thumb'] ?? $photo['id']); ?>
<?php echo faceai_sim_html($photoThumb); ?>
<?php endif; ?>
</div>
<strong><?php echo faceai_sim_html($photo['label'] ?? $photo['id']); ?></strong><br>
<small>ID foto: <?php echo faceai_sim_html($photo['id'] ?? ''); ?></small><br>
<small>Punto foto: <?php echo faceai_sim_html($photo['checkpoint'] ?? '-'); ?></small>
<strong><?php echo faceai_sim_html($photoLabel); ?></strong><br>
<small>ID foto: <?php echo faceai_sim_html($photoId); ?></small><br>
<small>Punto foto: <?php echo faceai_sim_html($photoCheckpoint); ?></small>
</div>
<?php endforeach; ?>
</div>
@ -226,6 +233,7 @@ window.faceAiSimulator = {
</script>
<?php endif; ?>
<script src="vendor/jquery/jquery.min.js"></script>
<script src="vendor/popper/popper.min.js"></script>
<script src="vendor/bootstrap/js/bootstrap.min.js"></script>
<script src="_js/rus-ecom-240621.js"></script>
</body>