End to end tests
All checks were successful
Publish FaceAI Container / publish (push) Successful in 2m42s
All checks were successful
Publish FaceAI Container / publish (push) Successful in 2m42s
This commit is contained in:
parent
c67bb02173
commit
2218c9a84c
26 changed files with 1016 additions and 37 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -67,3 +67,4 @@ www/admin/_V4/**
|
|||
www/csv/**
|
||||
www/admin/_sounds/**
|
||||
www/mp3/**
|
||||
faceai/logs/**
|
||||
|
|
@ -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
2
faceai/.gitignore
vendored
|
|
@ -1,3 +1,5 @@
|
|||
node_modules/
|
||||
apps/frontend/dist/
|
||||
.env
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,9 +96,22 @@ async function processJob(job) {
|
|||
pklRoot: config.pklRoot
|
||||
});
|
||||
|
||||
const csvPath = path.join(searchDir, 'result.csv');
|
||||
const logPath = path.join(searchDir, 'matcher.log');
|
||||
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,
|
||||
matcherLogPath: logPath,
|
||||
timeoutMs: config.workerTimeoutMs
|
||||
});
|
||||
|
||||
try {
|
||||
await runFaceMatcher({
|
||||
matcherBinary: config.matcherBinary,
|
||||
selfiePath: search.selfiePath,
|
||||
|
|
@ -45,20 +120,27 @@ async function processJob(job) {
|
|||
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;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
await markSearchCompleted(connection, searchId, result.id, matches.length, config.searchTtlSeconds);
|
||||
await releaseActiveSearchLock(connection, search.userId, searchId);
|
||||
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}`);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
8
faceai/docker/processor.Dockerfile
Normal file
8
faceai/docker/processor.Dockerfile
Normal 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
|
||||
64
faceai/package-lock.json
generated
64
faceai/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
faceai/playwright.config.js
Normal file
21
faceai/playwright.config.js
Normal 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'
|
||||
}
|
||||
});
|
||||
399
faceai/tests/e2e/faceai-simulator.spec.js
Normal file
399
faceai/tests/e2e/faceai-simulator.spec.js
Normal 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);
|
||||
}
|
||||
});
|
||||
203
faceai/tests/e2e/faceai-test-utils.js
Normal file
203
faceai/tests/e2e/faceai-test-utils.js
Normal 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
|
||||
};
|
||||
27
faceai/tests/e2e/global-setup.js
Normal file
27
faceai/tests/e2e/global-setup.js
Normal 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');
|
||||
});
|
||||
};
|
||||
9
faceai/tests/e2e/global-teardown.js
Normal file
9
faceai/tests/e2e/global-teardown.js
Normal 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 });
|
||||
};
|
||||
BIN
test_pkl/test_images/DSC_1960.JPG
Normal file
BIN
test_pkl/test_images/DSC_1960.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
test_pkl/test_images/DSC_1987.JPG
Normal file
BIN
test_pkl/test_images/DSC_1987.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 612 KiB |
BIN
test_pkl/test_images/DSC_1994.JPG
Normal file
BIN
test_pkl/test_images/DSC_1994.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 459 KiB |
BIN
test_pkl/test_images/DSC_2058.JPG
Normal file
BIN
test_pkl/test_images/DSC_2058.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 647 KiB |
BIN
test_pkl/test_images/DSC_2131.JPG
Normal file
BIN
test_pkl/test_images/DSC_2131.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 634 KiB |
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue