Enhance Docker and PowerShell scripts for improved functionality and maintainability
All checks were successful
Publish FaceAI Container / publish (push) Successful in 6m52s

- Updated Dockerfile to include default MySQL client for better database interaction.
- Modified entrypoint.sh to support additional workspace for legacy applications and added MySQL readiness check before startup.
- Enhanced PowerShell script for trimming MySQL dumps to include overlay dumps and improved error handling for missing race and user IDs.
- Added new image files and face encoding pickles for various projects, ensuring comprehensive data availability.
- Removed outdated face encoding pickle from PISA directory to maintain data relevance.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
MaddoScientisto 2026-04-22 22:45:44 +02:00
commit dd7d4c865b
54 changed files with 492 additions and 144 deletions

View file

@ -7,8 +7,7 @@ It includes:
- a Vue frontend for the FaceAI upload and polling flow
- a Node/Express backend for session exchange, queueing, and return handoff
- a dedicated processor runner that consumes matcher jobs from Redis and executes `face_matcher`
- a local legacy simulator so the launch and return flow can be tested without the old Java site
- a Dockerized PHP Apache stack for exercising the real `www/faceai_handoff.php` and `www/faceai_return.php` bridge files
- a local Dockerized Tomcat/JSP stack so the launch and return flow can be tested against the real legacy race pages under `www/`
## Structure
@ -24,32 +23,38 @@ faceai/
## Runtime Topology
The scaffold currently expects four runtime roles:
The production-oriented application topology is still three services:
- `faceai`: public HTTP service on port `3001`, serving the built Vue app and the authenticated API
- `processor`: background matcher runner consuming BullMQ jobs from Redis and executing the Linux `face_matcher` binary
- `redis`: short-lived queue and search-state store
- `legacy-php`: local-only PHP Apache simulator for exercising the real bridge files under `www/`
For hosted deployment, the long-lived application topology is `faceai` + `processor` + `redis`. The PHP simulator stays local-only and the real legacy site remains on its existing stack.
The checked-in development override now expands that into the full local integration stack:
- `tomcat-www`: local Tomcat runtime serving the real legacy JSP race pages from `www/`
- `mysql`: local legacy database used by the Tomcat stack
- `maildump`: local SMTP sink and viewer for the Tomcat stack
That means the local FaceAI flow now runs against the same legacy page type the real site uses instead of a separate PHP bridge container.
## What The End-To-End Local Test Covers
The local simulator exercises the exact flow the plan is aiming for:
The local Tomcat stack exercises the exact flow the current integration is aiming for:
1. a legacy-like race page loads the original `www/_js/rus-ecom-240621.js` script and shows a `Face ID` button instead of `tipoPuntoFoto`
2. clicking it hits the real PHP handoff bridge at `www/faceai_handoff.php`
2. clicking it uses the handoff URL emitted by the real JSP race page and launches FaceAI through `http://localhost:3001/dev/legacy/launch`
3. the backend signs a short-lived handoff token and redirects to the Vue app
4. the Vue app exchanges the token for its own FaceAI session cookie
5. the user uploads a selfie and starts a Redis-backed race-scoped search
6. the frontend polls until the job completes
7. FaceAI requests a signed return URL
8. the browser is redirected back to the real PHP return bridge at `www/faceai_return.php`
9. the PHP bridge fetches the signed result from FaceAI and renders a filtered legacy-like race page
8. the browser is redirected back to the real Tomcat race page with `faceaiResultId` and `faceaiToken`
9. the legacy race-page JavaScript fetches the signed result from FaceAI, stores the hydrated match payload in browser storage, and reloads the cleaned race URL
10. the same legacy race page renders the FaceAI-filtered gallery view from that stored payload
## Local Testing With The Legacy PHP Simulator
## Local Testing With The Merged Legacy JSP Stack
This is the recommended local test path because it exercises the public site, the processor, Redis, and the real PHP bridge files together.
This is the recommended local test path because it exercises the public site, the processor, Redis, and the real legacy JSP page flow together.
### Prerequisites
@ -78,13 +83,15 @@ The local development stack started by the command above combines the base file
- FaceAI public site on `http://localhost:3001`
- processor runner on the internal Compose network
- Redis on the internal Compose network
- PHP Apache serving `../www` on `http://localhost:8080`
- Tomcat serving the real local legacy site on `http://localhost:8080`
- MySQL for the local legacy stack
- Maildump for the local legacy stack on `http://localhost:8025`
The local stack also mounts:
- `../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
- `../www` into the Tomcat build/runtime inputs so the real legacy JSP pages and assets are used
The `processor` service is built from `docker/processor.Dockerfile` using the repository root as Docker build context. That image copies only the checked-in Unix `face_matcher` into the image, so the matcher is baked into the processor runtime without bringing along the other Unix or Windows binaries.
@ -110,22 +117,23 @@ The current bundled Linux `face_matcher` binary is a PyInstaller build that requ
Open:
```text
http://localhost:8080/faceai_simulator.php?raceId=202&lang=it
http://localhost:8080/Foto2.abl?id_gara=1018557&pageRow=96&pageNumber=1
```
That page simulates the legacy race page, loads the original race-page JavaScript from `www/_js/rus-ecom-240621.js`, lets the script replace the visible `tipoPuntoFoto` selector with the new `Face ID` button, and launches the real PHP handoff bridge at `www/faceai_handoff.php`.
That is the real local Tomcat race page. It loads the original race-page JavaScript from `www/_js/rus-ecom-240621.js`, lets the script replace the visible `tipoPuntoFoto` selector with the new `Face ID` button, and launches the backend handoff endpoint configured through the JSP page.
### Expected Local Flow
Use the page above and verify this sequence:
1. the simulator page renders on port `8080`
1. the real local race page renders on port `8080`
2. the visible checkpoint selector is replaced with the `Face ID` launch button
3. clicking `Face ID` redirects through `faceai_handoff.php` into `http://localhost:3001/auth/callback?token=...`
3. clicking `Face ID` redirects through `http://localhost:3001/dev/legacy/launch` into `http://localhost:3001/auth/callback?token=...`
4. the FaceAI app establishes its session and loads the upload flow
5. uploading a selfie creates a queued search that the processor picks up
6. when polling completes, FaceAI redirects back to `http://localhost:8080/faceai_return.php?...`
7. the PHP return page renders the filtered photo list from the FaceAI result payload
6. when polling completes, FaceAI redirects back to the same race page with `faceaiResultId` and `faceaiToken`
7. the race-page JavaScript hydrates the result from FaceAI and reloads the cleaned legacy URL
8. the filtered photo list renders from the hydrated FaceAI result payload
### Rebuild Notes
@ -143,7 +151,7 @@ docker compose --env-file .env.development 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.
The workspace now includes a Playwright suite that drives the real local Tomcat race page, the FaceAI app, and the processor end to end.
From this folder, run:
@ -157,10 +165,10 @@ The suite will:
- build the frontend bundle
- start `docker compose --env-file .env.development up --build -d`
- open `http://localhost:8080/faceai_simulator.php?raceId=202&lang=it`
- open `http://localhost:8080/Foto2.abl?id_gara=1018557&pageRow=96&pageNumber=1`
- 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`
- wait for the processor to complete and for FaceAI to redirect back to the legacy race page
- 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
@ -238,7 +246,7 @@ If a running processor still reports `ENOENT`, the deployed image was built befo
## 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.
If you only want to iterate on the app without the local Tomcat stack, 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.
One workable loop is:
@ -252,7 +260,7 @@ Then start the processor in a second shell, either with its own local environmen
## Docker Compose Deployment For The Public Site And Matcher Runner
The checked-in `docker-compose.yml` is now the production-ready base stack for hosted deployment. The checked-in `docker-compose.override.yml` is the development overlay that restores the local PHP simulator, workspace bind mounts, and development-oriented commands.
The checked-in `docker-compose.yml` is now the production-ready base stack for hosted deployment. The checked-in `docker-compose.override.yml` is the development overlay that restores the local Tomcat/JSP stack, workspace bind mounts, and development-oriented commands.
Because Docker Compose auto-loads `docker-compose.override.yml` when it is present in the same directory, production-style runs from this workspace must explicitly select only the base file.
@ -367,8 +375,8 @@ After the Compose stack is up, validate at least the following:
2. The legacy handoff endpoint redirects to `https://faceai.../auth/callback?token=...`.
3. FaceAI can exchange the token and establish a session.
4. A search is enqueued in Redis and picked up by the processor.
5. Completing a search produces a redirect URL that points to `FACEAI_LEGACY_RETURN_URL`.
6. The legacy return endpoint can resolve the signed result and render the filtered race page.
5. Completing a search produces a redirect URL that points to the intended legacy return target.
6. The legacy page can resolve the signed result and render the filtered race page.
### Current Production Limitations
@ -411,9 +419,10 @@ NODE_ENV=development
FACEAI_PORT=3001
FACEAI_FRONTEND_URL=http://localhost:3001
FACEAI_PUBLIC_BASE_URL=http://localhost:3001
FACEAI_LEGACY_RETURN_URL=http://localhost:8080/faceai_return.php
FACEAI_LEGACY_RETURN_MODE=direct
FACEAI_LEGACY_HOME_URL=http://localhost:8080/index.jsp
FACEAI_FEATURE_ENABLED=1
FACEAI_HANDOFF_URL=http://localhost:3001/dev/legacy/launch
FACEAI_SHARED_SECRET=change-me
FACEAI_SESSION_COOKIE=rus_faceai_session
FACEAI_REDIS_URL=redis://redis:6379
@ -425,12 +434,13 @@ FACEAI_PKL_ROOT=/data/pkl
FACEAI_MATCHER_BINARY=/app/bin/face_matcher
```
If you want FaceAI to return through the new PHP bridge prepared under `www`, point `FACEAI_LEGACY_RETURN_URL` to that endpoint instead, for example `http://localhost/faceai_return.php` or the equivalent URL in your local PHP setup.
If you want FaceAI to force the older bridge-style return path instead, set `FACEAI_LEGACY_RETURN_MODE=bridge` and point `FACEAI_LEGACY_RETURN_URL` at the appropriate legacy bridge endpoint.
In the development override, that wiring is already done with:
In the current development override, the local legacy handoff wiring is already done with:
```text
FACEAI_LEGACY_RETURN_URL=http://localhost:8080/faceai_return.php
FACEAI_HANDOFF_URL=http://localhost:3001/dev/legacy/launch
FACEAI_LEGACY_RETURN_MODE=direct
FACEAI_LEGACY_HOME_URL=http://localhost:8080/index.jsp
```
@ -438,18 +448,17 @@ The development override also keeps the log wiring with `./logs:/data/logs`, so
The Compose contract now also includes an HTTP healthcheck on the public FaceAI service and a Redis readiness check. That makes `docker compose ps` meaningful during rollout: `faceai` only becomes healthy after `GET /health` returns `{"ok":true}`, and both the public site and the processor wait for Redis readiness before their own startup sequence begins.
The local PHP simulator also needs the legacy bridge feature flag enabled:
The local Tomcat stack also needs the legacy bridge feature flag enabled on the JSP side:
```text
FACEAI_FEATURE_ENABLED=1
```
The checked-in `docker-compose.override.yml` sets that on the `legacy-php` service so the simulator can launch the FaceAI handoff flow locally.
The checked-in `docker-compose.override.yml` sets that on the local `tomcat-www` service so the race page can launch the FaceAI handoff flow locally.
## Notes
- Search orchestration now uses Redis and a dedicated processor worker.
- The checked-in base Compose file is production-oriented, while the checked-in override is development-only.
- The local legacy simulator is intentionally backend-driven so the handoff can be tested without compiling the existing Java application.
- `www/faceai_simulator.php` exists only for local testing. It does not replace the actual JSP race page.
- The local development flow now uses the actual Tomcat-served JSP race page instead of a separate PHP simulator bridge.
- The final legacy integration still needs a real signed identity source and a real return-filter implementation on the old site.

View file

@ -86,11 +86,12 @@ services:
FACEAI_DEV_DISPLAY_NAME: ${FACEAI_DEV_DISPLAY_NAME:-Local Model User}
FACEAI_DEV_EMAIL: ${FACEAI_DEV_EMAIL:-local.model.user@example.invalid}
FACEAI_DEV_MEMBERSHIP_STATUS: ${FACEAI_DEV_MEMBERSHIP_STATUS:-active}
LOCAL_APP_INSTANCE: ${LOCAL_APP_INSTANCE:-local-model}
LOCAL_APP_INSTANCE: ${LOCAL_APP_INSTANCE:-main}
LOCAL_DOCBASE: /data/docbase/
FACEAI_FEATURE_ENABLED: ${FACEAI_FEATURE_ENABLED:-1}
volumes:
- ../www:/workspace/www:ro
- ../rus:/workspace/rus:ro
- ../test_pkl:/workspace/test_pkl:ro
- ../local-jsp-docker/runtime/docbase:/data/docbase
- ../local-jsp-docker/runtime/tomcat-work:/usr/local/tomcat/work
@ -111,15 +112,21 @@ services:
- --max_allowed_packet=1G
- --net_read_timeout=600
- --net_write_timeout=600
- --lower_case_table_names=1
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: pg
MYSQL_ROOT_HOST: '%'
LOCAL_DB_SEED_DUMP: ${LOCAL_DB_SEED_DUMP:-pg-model-seed-trimmed-20260421.sql}
LOCAL_DB_SEED_DUMP: ${LOCAL_DB_SEED_DUMP:-pg-local-purpose-seed-20260422.sql}
LOCAL_DB_OVERLAY_DUMP: ${LOCAL_DB_OVERLAY_DUMP:-}
LOCAL_DOCBASE: /data/docbase/
LOCAL_MAIL_SMTP_HOST: maildump
LOCAL_MAIL_SMTP_PORT: 1025
LOCAL_SOURCE_DIR: /workspace/www/
LOCAL_TEST_USER_ID: ${LOCAL_TEST_USER_ID:-2}
LOCAL_TEST_USER_LOGIN: ${LOCAL_TEST_USER_LOGIN:-test}
LOCAL_TEST_USER_PASSWORD: ${LOCAL_TEST_USER_PASSWORD:-test1}
LOCAL_TEST_USER_EMAIL: ${LOCAL_TEST_USER_EMAIL:-localtest@regalamiunsorriso.test}
volumes:
- mysql-data:/var/lib/mysql
- ../db:/seed:ro
@ -128,11 +135,9 @@ services:
healthcheck:
test:
- CMD
- mysqladmin
- ping
- -h
- 127.0.0.1
- -proot
- sh
- -lc
- mysql -uroot -p"$$MYSQL_ROOT_PASSWORD" -Nse "SELECT CASE WHEN EXISTS (SELECT 1 FROM pg.parm WHERE codice = 'REWRITE_URL_ENABLE' AND numero = 1) AND EXISTS (SELECT 1 FROM pg.gara LIMIT 1) THEN 1 ELSE 0 END" | grep -qx 1
interval: 10s
timeout: 5s
retries: 30

View file

@ -1,8 +1,10 @@
const { test, expect } = require('@playwright/test');
const {
ensureLocalAuthenticatedRacePage,
EXPECTED_MATCH_COUNT,
FACEAI_BASE_URL,
LEGACY_RACE_ID,
SELFIE_NAME,
buildHandoffUrl,
buildSimulatorUrl,
getSearchArtifacts,
@ -61,6 +63,7 @@ async function readLaunchUrlFromLegacyPage(page) {
}
async function startSearch(page, selfieName) {
const selfieLabel = selfieName.split(/[\\/]+/u).pop();
const createResponsePromise = page.waitForResponse((response) => {
return response.url().includes('/api/searches')
&& response.request().method() === 'POST'
@ -68,7 +71,7 @@ async function startSearch(page, selfieName) {
});
await page.locator('input[type="file"]').setInputFiles(getSelfiePath(selfieName));
await expect(page.getByText(selfieName)).toBeVisible();
await expect(page.getByText(selfieLabel)).toBeVisible();
await page.getByRole('button', { name: 'Avvia ricerca Face ID' }).click();
const createResponse = await createResponsePromise;
@ -165,40 +168,40 @@ test('runs the legacy Tomcat flow through FaceAI and returns to the filtered leg
await launchFromSimulator(page, {
raceId: LEGACY_RACE_ID,
raceSlug: 'livorno',
raceName: 'Livorno',
raceFolder: 'LIVORNO'
raceSlug: 'isolotto',
raceName: 'Festa sociale UP Isolotto',
raceFolder: 'ISOLOTTO'
});
const search = await startSearch(page, 'DSC_1960.JPG');
const search = await startSearch(page, SELFIE_NAME);
await waitForLegacyResult(page, LEGACY_RACE_ID, EXPECTED_MATCH_COUNT);
await verifySearchLogs(search.id, {
expectedMatchCount: EXPECTED_MATCH_COUNT,
expectedSelfieName: 'DSC_1960.JPG'
expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop()
});
});
test('builds the legacy FaceAI handoff URL with the exact local race storage metadata', async ({ page }) => {
await page.goto(buildSimulatorUrl({
raceId: LEGACY_RACE_ID,
raceSlug: 'livorno',
raceName: 'Livorno',
raceSlug: 'isolotto',
raceName: 'Festa sociale UP Isolotto',
raceYear: '2026',
raceMonthFolder: '04.APRILE',
raceFolder: 'LIVORNO'
raceFolder: 'ISOLOTTO'
}), { waitUntil: 'domcontentloaded' });
await expect(page.locator('#faceAiRaceYear')).toHaveValue('2026');
await expect(page.locator('#faceAiRaceMonthFolder')).toHaveValue('04.APRILE');
await expect(page.locator('#faceAiRaceFolder')).toHaveValue('LIVORNO');
await expect(page.locator('#faceAiRaceFolder')).toHaveValue('ISOLOTTO');
const launchUrl = await readLaunchUrlFromLegacyPage(page);
expect(launchUrl.searchParams.get('raceYear')).toBe('2026');
expect(launchUrl.searchParams.get('raceMonthFolder')).toBe('04.APRILE');
expect(launchUrl.searchParams.get('raceFolder')).toBe('LIVORNO');
expect(launchUrl.searchParams.get('raceStorageRelativeDir')).toBe('2026/04.APRILE/LIVORNO');
expect(launchUrl.searchParams.get('raceFolder')).toBe('ISOLOTTO');
expect(launchUrl.searchParams.get('raceStorageRelativeDir')).toBe('2026/04.APRILE/ISOLOTTO');
});
test('shows the unsupported-race message when the current race has no PKL data and lets the user go back', async ({ page }) => {
@ -300,6 +303,19 @@ test('rejects a not-logged-in user after clicking the Face ID button and sends t
await expect(page.locator('#faceAiErrorModalMessage')).toContainText('Il servizio Face ID non e al momento disponibile. Riprova piu tardi.');
});
test('authenticates with the seeded local user and lets that user browse and launch the Livorno race page', async ({ page }) => {
await ensureLocalAuthenticatedRacePage(page, {
raceId: '1018557',
raceName: 'VIVICITTA LIVORNO',
raceYear: '2026',
raceMonthFolder: '04.APRILE',
raceFolder: 'LIVORNO'
});
await page.locator('#faceaiLaunchButton').click();
await waitForFaceAiHome(page);
});
test('shows the no-face message and allows the user to return to the race page', async ({ page }) => {
test.slow();
@ -341,8 +357,8 @@ test('opens the file chooser when the user clicks the upload button', async ({ p
await page.getByRole('button', { name: 'Scegli immagine' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(getSelfiePath('DSC_1960.JPG'));
await expect(page.getByText('DSC_1960.JPG')).toBeVisible();
await fileChooser.setFiles(getSelfiePath(SELFIE_NAME));
await expect(page.getByText(SELFIE_NAME.split(/[\\/]+/u).pop())).toBeVisible();
await expect(page.getByRole('button', { name: 'Avvia ricerca Face ID' })).toBeEnabled();
});
@ -364,7 +380,7 @@ test('lets the user retry with a valid photo after a no-face upload and then ret
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');
const retrySearch = await startSearch(page, SELFIE_NAME);
await waitForLegacyResult(page, '202', EXPECTED_MATCH_COUNT);
await verifySearchLogs(noFaceSearch.id, {
@ -373,7 +389,7 @@ test('lets the user retry with a valid photo after a no-face upload and then ret
});
await verifySearchLogs(retrySearch.id, {
expectedMatchCount: EXPECTED_MATCH_COUNT,
expectedSelfieName: 'DSC_1960.JPG'
expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop()
});
});
@ -403,7 +419,7 @@ test('allows two users to process different photos at the same time', async ({ b
]);
const [searchOne, searchTwo] = await Promise.all([
startSearch(pages[0], 'DSC_1960.JPG'),
startSearch(pages[0], SELFIE_NAME),
startSearch(pages[1], 'DSC_1987.JPG')
]);
@ -413,7 +429,7 @@ test('allows two users to process different photos at the same time', async ({ b
]);
await Promise.all([
verifySearchLogs(searchOne.id, { expectedSelfieName: 'DSC_1960.JPG' }),
verifySearchLogs(searchOne.id, { expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop() }),
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' })
]);
} finally {
@ -436,7 +452,7 @@ test('queues the third user until a worker is free and then completes all three
]);
const [searchOne, searchTwo, searchThree] = await Promise.all([
startSearch(pages[0], 'DSC_1960.JPG'),
startSearch(pages[0], SELFIE_NAME),
startSearch(pages[1], 'DSC_1987.JPG'),
startSearch(pages[2], 'DSC_2058.JPG')
]);
@ -477,7 +493,7 @@ test('queues the third user until a worker is free and then completes all three
await Promise.all(pages.map((page) => waitForLegacyResult(page, '202')));
await Promise.all([
verifySearchLogs(searchOne.id, { expectedSelfieName: 'DSC_1960.JPG' }),
verifySearchLogs(searchOne.id, { expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop() }),
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' }),
verifySearchLogs(searchThree.id, { expectedSelfieName: 'DSC_2058.JPG' })
]);

View file

@ -7,12 +7,15 @@ 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/Foto2.abl?id_gara=1018557&pageRow=96&pageNumber=1';
const SIMULATOR_URL = process.env.FACEAI_E2E_SIMULATOR_URL || 'http://127.0.0.1:8080/Foto2.abl?id_gara=1018547&pageRow=96&pageNumber=1';
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 LEGACY_LOGIN_URL = process.env.FACEAI_E2E_LEGACY_LOGIN_URL || `${LEGACY_BASE_URL}/login_clienti.html`;
const LEGACY_USERNAME = process.env.FACEAI_E2E_LEGACY_USERNAME || 'test';
const LEGACY_PASSWORD = process.env.FACEAI_E2E_LEGACY_PASSWORD || 'test1';
const SELFIE_NAME = process.env.FACEAI_E2E_SELFIE || '2026/04.APRILE/ISOLOTTO/01.FOTO/CKL_8459.JPG';
const EXPECTED_MATCH_COUNT = Number(process.env.FACEAI_E2E_EXPECTED_MATCH_COUNT || '6');
const LEGACY_RACE_ID = process.env.FACEAI_E2E_RACE_ID || '1018557';
const LEGACY_RACE_ID = process.env.FACEAI_E2E_RACE_ID || '1018547';
function quoteShellArg(value) {
if (!/[\s"]/u.test(value)) {
@ -114,17 +117,22 @@ async function waitForHttp(url, validate, timeoutMs = 3 * 60 * 1000) {
}
function getSelfiePath(fileName = SELFIE_NAME) {
const relativeSegments = fileName.split(/[\\/]+/u);
if (relativeSegments.length > 1) {
return path.join(WORKSPACE_ROOT, 'test_pkl', ...relativeSegments);
}
return path.join(WORKSPACE_ROOT, 'test_pkl', 'test_images', fileName);
}
function buildSimulatorUrl({
raceId = LEGACY_RACE_ID,
lang = 'it',
raceSlug = 'livorno',
raceName = 'Livorno',
raceSlug = 'isolotto',
raceName = 'Festa sociale UP Isolotto',
raceYear = '2026',
raceMonthFolder = '04.APRILE',
raceFolder = 'LIVORNO',
raceFolder = 'ISOLOTTO',
pageRow = '96',
pageNumber = '1'
} = {}) {
@ -139,11 +147,11 @@ function buildSimulatorUrl({
function buildHandoffUrl({
raceId = LEGACY_RACE_ID,
lang = 'it',
raceSlug = 'livorno',
raceName = 'Livorno',
raceSlug = 'isolotto',
raceName = 'Festa sociale UP Isolotto',
raceYear = '2026',
raceMonthFolder = '04.APRILE',
raceFolder = 'LIVORNO',
raceFolder = 'ISOLOTTO',
userId = '1',
displayName = `Local Test User ${userId}`,
email = `local-test-${userId}@example.invalid`,
@ -166,6 +174,86 @@ function buildHandoffUrl({
return url.toString();
}
function buildLegacyLoginFormData() {
if (!LEGACY_USERNAME || !LEGACY_PASSWORD) {
throw new Error('FACEAI_E2E_LEGACY_USERNAME and FACEAI_E2E_LEGACY_PASSWORD must be set before running authenticated local E2E checks.');
}
return {
login: LEGACY_USERNAME,
pwd: LEGACY_PASSWORD,
cmdIU: 'check',
act: '',
thePage: ''
};
}
async function performLocalLoginRequest(requestContext) {
const response = await requestContext.post(`${LEGACY_BASE_URL}/Logon.abl`, {
form: buildLegacyLoginFormData(),
failOnStatusCode: false
});
const finalUrl = response.url();
const bodyText = await response.text();
if (!response.ok()) {
throw new Error(`Local login request failed with HTTP ${response.status()} at ${finalUrl}`);
}
if (/login_clienti|Username \/ Email|Password/iu.test(bodyText) && !/user_logout|dettaglio_clienti|Il mio account/iu.test(bodyText)) {
throw new Error(`Local login request appears to have remained on the login page at ${finalUrl}`);
}
}
async function expectLocalRacePageLoaded(page) {
await page.waitForSelector('form[onsubmit="return searching()"]', { state: 'visible' });
const raceId = await page.locator('#id_gara').inputValue();
if (!/\d+/u.test(raceId)) {
throw new Error(`Expected the local race page to expose a numeric race id, got: ${raceId}`);
}
await page.waitForSelector('#faceaiLaunchButton', { state: 'visible' });
await page.waitForFunction(() => {
return document.querySelectorAll('a[data-faceai-photo-id] img.thumb').length > 0;
}, null, {
timeout: 30 * 1000
});
}
async function ensureLocalAuthenticatedRacePage(page, options = {}) {
await page.goto(LEGACY_LOGIN_URL, { waitUntil: 'domcontentloaded' });
await page.locator('#login').fill(LEGACY_USERNAME);
await page.locator('#pwd').fill(LEGACY_PASSWORD);
const submitLocator = page.locator('input[type="submit"], button[type="submit"], a.btn').filter({ hasText: /Accedi|Sign in/i }).first();
if (await submitLocator.count()) {
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
submitLocator.click()
]);
} else {
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.evaluate(() => {
const form = document.querySelector('form[action="Logon.abl"]');
if (!form) {
throw new Error('Local login page did not expose a Logon.abl form.');
}
const cmdIUField = form.querySelector('[name="cmdIU"]');
if (cmdIUField) {
cmdIUField.value = 'check';
}
form.submit();
})
]);
}
await page.goto(buildSimulatorUrl(options), { waitUntil: 'domcontentloaded' });
await expectLocalRacePageLoaded(page);
}
function getSearchArtifacts(searchId) {
const searchRoot = path.join(SEARCH_LOG_ROOT, searchId);
return {
@ -188,15 +276,22 @@ module.exports = {
FACEAI_BASE_URL,
LEGACY_BASE_URL,
LEGACY_HOME_URL,
LEGACY_LOGIN_URL,
LEGACY_PASSWORD,
SIMULATOR_URL,
SELFIE_NAME,
EXPECTED_MATCH_COUNT,
LEGACY_RACE_ID,
LEGACY_USERNAME,
buildHandoffUrl,
buildLegacyLoginFormData,
buildSimulatorUrl,
dockerCompose,
ensureLocalAuthenticatedRacePage,
expectLocalRacePageLoaded,
getSearchArtifacts,
getSelfiePath,
performLocalLoginRequest,
prepareHostState,
readUtf8,
runCommand,