Enhance FaceAI integration with live checks and metadata handling
All checks were successful
Publish FaceAI Container / publish (push) Successful in 3m22s

- Added optional live FaceAI checks in README.md
- Implemented relative storage segment parsing in race-storage.js
- Updated server.js to include relative directory in race storage
- Refactored legacyAssets.js to resolve asset base URL dynamically
- Expanded live race tests to validate FaceAI app launch and metadata
- Introduced portrait image handling for live upload flow
- Updated faceai_handoff.php to process race storage relative directory
This commit is contained in:
MaddoScientisto 2026-04-19 10:38:32 +02:00
commit bb60201ad4
9 changed files with 214 additions and 25 deletions

View file

@ -201,6 +201,21 @@ What it does:
- opens the configured live race URL - opens the configured live race URL
- verifies the account UI is present and the race search form renders correctly - verifies the account UI is present and the race search form renders correctly
Optional live FaceAI checks can also be enabled with:
```bash
LIVE_FACEAI_BASE_URL=https://ai.regalamiunsorriso.it
LIVE_SITE_PORTRAIT_PATH=../test_pkl/live/test_portrait_1.png
LIVE_SITE_RUN_UPLOAD_FLOW=1
```
When enabled, the live suite also:
- validates that the legacy Face ID handoff URL includes the race storage metadata expected by FaceAI
- opens the real FaceAI app and asserts that the legacy header stylesheets load from the live legacy site
- confirms the app does not emit the `MISSING_RACE_STORAGE` invalid-race error on launch
- uploads the supplied portrait image and verifies that search creation succeeds
## Optional Backend And Frontend Dev Loop ## 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 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.

View file

@ -30,6 +30,20 @@ function sanitizePathSegment(value) {
return normalized; return normalized;
} }
function parseRelativeStorageSegments(value) {
const normalized = String(value || '').trim().replace(/\\/g, '/');
if (!normalized) {
return [];
}
return normalized
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.map((segment) => sanitizePathSegment(segment));
}
export function normalizeRaceFolderName(value) { export function normalizeRaceFolderName(value) {
return String(value || '') return String(value || '')
.trim() .trim()
@ -49,9 +63,16 @@ export function buildMonthFolder(year, monthIndex) {
} }
export function buildRaceStorage(storageInput = {}) { export function buildRaceStorage(storageInput = {}) {
const year = sanitizePathSegment(storageInput.year); const relativeSegments = parseRelativeStorageSegments(storageInput.relativeDir);
const monthFolder = sanitizePathSegment(storageInput.monthFolder); const relativeYear = relativeSegments[0] || '';
const raceFolder = sanitizePathSegment(normalizeRaceFolderName(storageInput.raceFolder)); const relativeMonthFolder = relativeSegments.length >= 3 ? relativeSegments[1] : '';
const relativeRaceFolder = relativeSegments.length >= 3
? relativeSegments[2]
: (relativeSegments.length >= 2 ? relativeSegments[1] : '');
const year = sanitizePathSegment(storageInput.year || relativeYear);
const monthFolder = sanitizePathSegment(storageInput.monthFolder || relativeMonthFolder);
const raceFolder = sanitizePathSegment(normalizeRaceFolderName(storageInput.raceFolder || relativeRaceFolder));
if (!year || !monthFolder || !raceFolder) { if (!year || !monthFolder || !raceFolder) {
return null; return null;

View file

@ -262,7 +262,8 @@ app.get('/dev/legacy/launch', (req, res) => {
raceStorage: { raceStorage: {
year: String(req.query.raceYear || mockCatalog[raceId]?.storage?.year || ''), year: String(req.query.raceYear || mockCatalog[raceId]?.storage?.year || ''),
monthFolder: String(req.query.raceMonthFolder || mockCatalog[raceId]?.storage?.monthFolder || ''), monthFolder: String(req.query.raceMonthFolder || mockCatalog[raceId]?.storage?.monthFolder || ''),
raceFolder: String(req.query.raceFolder || mockCatalog[raceId]?.storage?.raceFolder || '') raceFolder: String(req.query.raceFolder || mockCatalog[raceId]?.storage?.raceFolder || ''),
relativeDir: String(req.query.raceStorageRelativeDir || '')
}, },
lang, lang,
returnUrl returnUrl

View file

@ -1,4 +1,33 @@
const legacyAssetBaseUrl = (import.meta.env.VITE_LEGACY_ASSET_BASE_URL || '/legacy-static').replace(/\/$/, ''); import { getLegacyBaseUrl } from './legacyUrls.js';
const localHostnames = new Set(['localhost', '127.0.0.1', '::1']);
function trimTrailingSlash(value) {
return String(value || '').replace(/\/$/, '');
}
function currentHostname() {
if (typeof window === 'undefined' || !window.location || !window.location.hostname) {
return '';
}
return window.location.hostname.toLowerCase();
}
function resolveLegacyAssetBaseUrl() {
const configuredAssetBaseUrl = trimTrailingSlash(import.meta.env.VITE_LEGACY_ASSET_BASE_URL || '');
if (configuredAssetBaseUrl) {
return configuredAssetBaseUrl;
}
if (localHostnames.has(currentHostname())) {
return '/legacy-static';
}
return getLegacyBaseUrl();
}
const legacyAssetBaseUrl = resolveLegacyAssetBaseUrl();
export function legacyAsset(path) { export function legacyAsset(path) {
return `${legacyAssetBaseUrl}${path.startsWith('/') ? path : `/${path}`}`; return `${legacyAssetBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;

View file

@ -1,28 +1,59 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { const {
LIVE_FACEAI_BASE_URL,
LIVE_SITE_BASE_URL,
LIVE_SITE_PORTRAIT_PATH,
LIVE_SITE_RACE_URL, LIVE_SITE_RACE_URL,
dismissCookieBanner, LIVE_SITE_RUN_UPLOAD_FLOW,
expectRacePageLoaded, ensureLiveAuthenticatedRacePage,
performLiveLogin, requirePortraitFixture
waitForLoggedInUi
} = require('./live-site-test-utils'); } = require('./live-site-test-utils');
test('loads a live race page with an authenticated session', async ({ page }) => { function escapeRegExp(value) {
await page.goto(LIVE_SITE_RACE_URL, { waitUntil: 'domcontentloaded' }); return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
await dismissCookieBanner(page); }
try { async function openLiveFaceAi(page) {
await waitForLoggedInUi(page); const consoleErrors = [];
} catch (error) {
await performLiveLogin(page);
await page.goto(LIVE_SITE_RACE_URL, { waitUntil: 'domcontentloaded' });
await dismissCookieBanner(page);
await waitForLoggedInUi(page);
}
await expectRacePageLoaded(page); page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
await ensureLiveAuthenticatedRacePage(page);
await expect(page.locator('h1')).toContainText(/HALF MARATHON FIRENZE|Competitions|Gare/i); await expect(page.locator('h1')).toContainText(/HALF MARATHON FIRENZE|Competitions|Gare/i);
const launchUrl = await page.evaluate(() => {
return typeof buildFaceAiLaunchUrl === 'function' ? buildFaceAiLaunchUrl() : '';
});
expect(launchUrl, 'Expected the legacy race page script to expose a FaceAI handoff URL builder.').toBeTruthy();
const parsedLaunchUrl = new URL(launchUrl, LIVE_SITE_BASE_URL);
expect(parsedLaunchUrl.searchParams.get('raceYear')).toBeTruthy();
expect(parsedLaunchUrl.searchParams.get('raceMonthFolder')).toBeTruthy();
expect(parsedLaunchUrl.searchParams.get('raceFolder')).toBeTruthy();
expect(parsedLaunchUrl.searchParams.get('raceStorageRelativeDir')).toBeTruthy();
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
await page.locator('#faceaiLaunchButton').click();
await page.waitForURL(new RegExp(`^${escapeRegExp(LIVE_FACEAI_BASE_URL)}/`), {
timeout: 60 * 1000
});
await expect(page.getByRole('heading', { name: /Trova le tue foto con un selfie|Find your photos with a selfie/i })).toBeVisible();
return {
consoleErrors,
launchUrl: parsedLaunchUrl
};
}
test('loads a live race page with an authenticated session', async ({ page }) => {
await ensureLiveAuthenticatedRacePage(page);
const cookies = await page.context().cookies(LIVE_SITE_RACE_URL); const cookies = await page.context().cookies(LIVE_SITE_RACE_URL);
const faceAiIdentityCookie = cookies.find((cookie) => cookie.name === 'rus_faceai_identity'); const faceAiIdentityCookie = cookies.find((cookie) => cookie.name === 'rus_faceai_identity');
@ -31,3 +62,60 @@ test('loads a live race page with an authenticated session', async ({ page }) =>
expect(faceAiIdentityCookie.secure).toBe(true); expect(faceAiIdentityCookie.secure).toBe(true);
expect(faceAiIdentityCookie.value).toMatch(/\./); expect(faceAiIdentityCookie.value).toMatch(/\./);
}); });
test('launches the live FaceAI app with race storage metadata and a styled header', async ({ page }) => {
const { consoleErrors, launchUrl } = await openLiveFaceAi(page);
expect(launchUrl.searchParams.get('raceStorageRelativeDir')).toContain('/');
await expect(page.locator('nav.navbar')).toBeVisible();
await expect(page.locator('link[data-legacy-href*="bootstrap.min.css"]')).toHaveCount(1);
await expect(page.locator('link[data-legacy-href*="custom-style.css"]')).toHaveCount(1);
const legacyStylesheetHrefs = await page.locator('link[data-legacy-href]').evaluateAll((elements) => {
return elements.map((element) => element.getAttribute('href') || '');
});
expect(legacyStylesheetHrefs.some((href) => href.startsWith(`${LIVE_SITE_BASE_URL}/`))).toBe(true);
const navComputedStyles = await page.locator('nav.navbar').evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
position: styles.position,
display: styles.display
};
});
expect(navComputedStyles.position).toBe('fixed');
expect(navComputedStyles.display).not.toBe('block');
expect(consoleErrors.find((entry) => entry.includes('MISSING_RACE_STORAGE')) || '').toBe('');
expect(consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || '').toBe('');
});
test.skip(!LIVE_SITE_RUN_UPLOAD_FLOW, 'Set LIVE_SITE_RUN_UPLOAD_FLOW=1 to exercise the live upload flow.');
test('accepts the supplied portrait image for the live upload flow', async ({ page }) => {
requirePortraitFixture();
await openLiveFaceAi(page);
const fileInput = page.locator('input[type="file"]');
await expect(fileInput).toBeEnabled();
await fileInput.setInputFiles(LIVE_SITE_PORTRAIT_PATH);
await expect(page.locator('.faceai-file-name')).toContainText('test_portrait_1.png');
const searchResponsePromise = page.waitForResponse((response) => {
return response.request().method() === 'POST' && response.url().includes('/api/searches');
}, {
timeout: 60 * 1000
});
await page.getByRole('button', { name: /Avvia ricerca Face ID|Start Face ID search/i }).click();
const searchResponse = await searchResponsePromise;
expect(searchResponse.ok(), 'Expected the live upload flow to create a FaceAI search successfully.').toBe(true);
const searchPayload = await searchResponse.json();
expect(searchPayload.id || searchPayload.searchId, 'Expected the search creation response to include a search identifier.').toBeTruthy();
await expect(page.locator('.faceai-feedback')).not.toContainText(/Impossibile|Unable|Errore|Error/i);
});

View file

@ -2,11 +2,15 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { expect } = require('@playwright/test'); const { expect } = require('@playwright/test');
const WORKSPACE_ROOT = path.resolve(__dirname, '..', '..', '..');
const LIVE_SITE_BASE_URL = process.env.LIVE_SITE_BASE_URL || 'https://www.regalamiunsorriso.it'; const LIVE_SITE_BASE_URL = process.env.LIVE_SITE_BASE_URL || 'https://www.regalamiunsorriso.it';
const LIVE_SITE_LOGIN_URL = process.env.LIVE_SITE_LOGIN_URL || `${LIVE_SITE_BASE_URL}/login_clienti-it.html`; const LIVE_SITE_LOGIN_URL = process.env.LIVE_SITE_LOGIN_URL || `${LIVE_SITE_BASE_URL}/login_clienti-it.html`;
const LIVE_SITE_RACE_URL = process.env.LIVE_SITE_RACE_URL || `${LIVE_SITE_BASE_URL}/42%20HALF%20MARATHON%20FIRENZE_gara-1018545---96-1.html`; const LIVE_SITE_RACE_URL = process.env.LIVE_SITE_RACE_URL || `${LIVE_SITE_BASE_URL}/42%20HALF%20MARATHON%20FIRENZE_gara-1018545---96-1.html`;
const LIVE_FACEAI_BASE_URL = process.env.LIVE_FACEAI_BASE_URL || 'https://ai.regalamiunsorriso.it';
const LIVE_SITE_USERNAME = process.env.LIVE_SITE_USERNAME || ''; const LIVE_SITE_USERNAME = process.env.LIVE_SITE_USERNAME || '';
const LIVE_SITE_PASSWORD = process.env.LIVE_SITE_PASSWORD || ''; const LIVE_SITE_PASSWORD = process.env.LIVE_SITE_PASSWORD || '';
const LIVE_SITE_PORTRAIT_PATH = process.env.LIVE_SITE_PORTRAIT_PATH || path.join(WORKSPACE_ROOT, 'test_pkl', 'live', 'test_portrait_1.png');
const LIVE_SITE_RUN_UPLOAD_FLOW = process.env.LIVE_SITE_RUN_UPLOAD_FLOW === '1';
const AUTH_FILE = path.join(__dirname, '.auth', 'user.json'); const AUTH_FILE = path.join(__dirname, '.auth', 'user.json');
function ensureAuthDirectory() { function ensureAuthDirectory() {
@ -75,18 +79,45 @@ async function expectRacePageLoaded(page) {
await expect(page.locator('script[src*="_js/rus-ecom-240621.js"]')).toHaveCount(1); await expect(page.locator('script[src*="_js/rus-ecom-240621.js"]')).toHaveCount(1);
} }
async function ensureLiveAuthenticatedRacePage(page) {
await page.goto(LIVE_SITE_RACE_URL, { waitUntil: 'domcontentloaded' });
await dismissCookieBanner(page);
try {
await waitForLoggedInUi(page);
} catch (error) {
await performLiveLogin(page);
await page.goto(LIVE_SITE_RACE_URL, { waitUntil: 'domcontentloaded' });
await dismissCookieBanner(page);
await waitForLoggedInUi(page);
}
await expectRacePageLoaded(page);
}
function requirePortraitFixture() {
if (!fs.existsSync(LIVE_SITE_PORTRAIT_PATH)) {
throw new Error(`LIVE_SITE_PORTRAIT_PATH does not exist: ${LIVE_SITE_PORTRAIT_PATH}`);
}
}
module.exports = { module.exports = {
AUTH_FILE, AUTH_FILE,
LIVE_FACEAI_BASE_URL,
LIVE_SITE_BASE_URL, LIVE_SITE_BASE_URL,
LIVE_SITE_LOGIN_URL, LIVE_SITE_LOGIN_URL,
LIVE_SITE_PASSWORD, LIVE_SITE_PASSWORD,
LIVE_SITE_PORTRAIT_PATH,
LIVE_SITE_RACE_URL, LIVE_SITE_RACE_URL,
LIVE_SITE_RUN_UPLOAD_FLOW,
LIVE_SITE_USERNAME, LIVE_SITE_USERNAME,
dismissCookieBanner, dismissCookieBanner,
ensureLiveAuthenticatedRacePage,
ensureAuthDirectory, ensureAuthDirectory,
expectRacePageLoaded, expectRacePageLoaded,
loginSubmitLocator, loginSubmitLocator,
performLiveLogin, performLiveLogin,
requirePortraitFixture,
requireCredentials, requireCredentials,
waitForLoggedInUi waitForLoggedInUi
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View file

@ -258,6 +258,7 @@ function buildFaceAiLaunchUrl() {
var raceYear = getFaceAiStorageValue("faceAiRaceYear", "year"); var raceYear = getFaceAiStorageValue("faceAiRaceYear", "year");
var raceMonthFolder = getFaceAiStorageValue("faceAiRaceMonthFolder", "monthFolder"); var raceMonthFolder = getFaceAiStorageValue("faceAiRaceMonthFolder", "monthFolder");
var raceFolder = getFaceAiStorageValue("faceAiRaceFolder", "raceFolder"); var raceFolder = getFaceAiStorageValue("faceAiRaceFolder", "raceFolder");
var raceStorageRelativeDir = $("#faceAiRaceStorageRelativeDir").val() || [raceYear, raceMonthFolder, raceFolder].filter(Boolean).join("/");
var lang = getCurrentLangValue(); var lang = getCurrentLangValue();
var handoffUrl = (window.faceAiSimulator && window.faceAiSimulator.handoffUrl) || "faceai_handoff.php"; var handoffUrl = (window.faceAiSimulator && window.faceAiSimulator.handoffUrl) || "faceai_handoff.php";
var returnUrl = (window.faceAiSimulator && window.faceAiSimulator.returnUrl) || window.location.href; var returnUrl = (window.faceAiSimulator && window.faceAiSimulator.returnUrl) || window.location.href;
@ -268,6 +269,7 @@ function buildFaceAiLaunchUrl() {
"raceYear=" + encodeURIComponent(raceYear), "raceYear=" + encodeURIComponent(raceYear),
"raceMonthFolder=" + encodeURIComponent(raceMonthFolder), "raceMonthFolder=" + encodeURIComponent(raceMonthFolder),
"raceFolder=" + encodeURIComponent(raceFolder), "raceFolder=" + encodeURIComponent(raceFolder),
"raceStorageRelativeDir=" + encodeURIComponent(raceStorageRelativeDir),
"lang=" + encodeURIComponent(lang), "lang=" + encodeURIComponent(lang),
"returnUrl=" + encodeURIComponent(returnUrl) "returnUrl=" + encodeURIComponent(returnUrl)
]; ];

View file

@ -11,6 +11,7 @@ try {
$raceYear = faceai_request_value('raceYear'); $raceYear = faceai_request_value('raceYear');
$raceMonthFolder = faceai_request_value('raceMonthFolder'); $raceMonthFolder = faceai_request_value('raceMonthFolder');
$raceFolder = faceai_request_value('raceFolder'); $raceFolder = faceai_request_value('raceFolder');
$raceStorageRelativeDir = faceai_request_value('raceStorageRelativeDir');
$lang = faceai_request_value('lang', 'it'); $lang = faceai_request_value('lang', 'it');
$returnUrl = faceai_request_value('returnUrl'); $returnUrl = faceai_request_value('returnUrl');
@ -45,11 +46,12 @@ try {
'name' => $raceName !== '' ? $raceName : $raceId 'name' => $raceName !== '' ? $raceName : $raceId
); );
if ($raceYear !== '' && $raceMonthFolder !== '' && $raceFolder !== '') { if ($raceYear !== '' || $raceMonthFolder !== '' || $raceFolder !== '' || $raceStorageRelativeDir !== '') {
$racePayload['storage'] = array( $racePayload['storage'] = array(
'year' => $raceYear, 'year' => $raceYear,
'monthFolder' => $raceMonthFolder, 'monthFolder' => $raceMonthFolder,
'raceFolder' => strtoupper(trim($raceFolder)) 'raceFolder' => strtoupper(trim($raceFolder)),
'relativeDir' => $raceStorageRelativeDir
); );
} }