diff --git a/faceai/README.md b/faceai/README.md index bdc44c46..ec5ea4d8 100644 --- a/faceai/README.md +++ b/faceai/README.md @@ -201,6 +201,21 @@ What it does: - opens the configured live race URL - 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 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. diff --git a/faceai/apps/backend/src/race-storage.js b/faceai/apps/backend/src/race-storage.js index 0a8ee4f7..0b150995 100644 --- a/faceai/apps/backend/src/race-storage.js +++ b/faceai/apps/backend/src/race-storage.js @@ -30,6 +30,20 @@ function sanitizePathSegment(value) { 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) { return String(value || '') .trim() @@ -49,9 +63,16 @@ export function buildMonthFolder(year, monthIndex) { } export function buildRaceStorage(storageInput = {}) { - const year = sanitizePathSegment(storageInput.year); - const monthFolder = sanitizePathSegment(storageInput.monthFolder); - const raceFolder = sanitizePathSegment(normalizeRaceFolderName(storageInput.raceFolder)); + const relativeSegments = parseRelativeStorageSegments(storageInput.relativeDir); + const relativeYear = relativeSegments[0] || ''; + 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) { return null; diff --git a/faceai/apps/backend/src/server.js b/faceai/apps/backend/src/server.js index da9f901f..e503def3 100644 --- a/faceai/apps/backend/src/server.js +++ b/faceai/apps/backend/src/server.js @@ -262,7 +262,8 @@ app.get('/dev/legacy/launch', (req, res) => { raceStorage: { year: String(req.query.raceYear || mockCatalog[raceId]?.storage?.year || ''), 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, returnUrl diff --git a/faceai/apps/frontend/src/legacyAssets.js b/faceai/apps/frontend/src/legacyAssets.js index 07e8a100..4c6e46ba 100644 --- a/faceai/apps/frontend/src/legacyAssets.js +++ b/faceai/apps/frontend/src/legacyAssets.js @@ -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) { return `${legacyAssetBaseUrl}${path.startsWith('/') ? path : `/${path}`}`; diff --git a/faceai/tests/live-site/live-race.spec.js b/faceai/tests/live-site/live-race.spec.js index 1ae3a2d0..c98f9ecb 100644 --- a/faceai/tests/live-site/live-race.spec.js +++ b/faceai/tests/live-site/live-race.spec.js @@ -1,28 +1,59 @@ const { test, expect } = require('@playwright/test'); const { + LIVE_FACEAI_BASE_URL, + LIVE_SITE_BASE_URL, + LIVE_SITE_PORTRAIT_PATH, LIVE_SITE_RACE_URL, - dismissCookieBanner, - expectRacePageLoaded, - performLiveLogin, - waitForLoggedInUi + LIVE_SITE_RUN_UPLOAD_FLOW, + ensureLiveAuthenticatedRacePage, + requirePortraitFixture } = require('./live-site-test-utils'); -test('loads a live race page with an authenticated session', async ({ page }) => { - await page.goto(LIVE_SITE_RACE_URL, { waitUntil: 'domcontentloaded' }); - await dismissCookieBanner(page); +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} - try { - await waitForLoggedInUi(page); - } catch (error) { - await performLiveLogin(page); - await page.goto(LIVE_SITE_RACE_URL, { waitUntil: 'domcontentloaded' }); - await dismissCookieBanner(page); - await waitForLoggedInUi(page); - } +async function openLiveFaceAi(page) { + const consoleErrors = []; - 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); + 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 faceAiIdentityCookie = cookies.find((cookie) => cookie.name === 'rus_faceai_identity'); @@ -30,4 +61,61 @@ test('loads a live race page with an authenticated session', async ({ page }) => expect(faceAiIdentityCookie.httpOnly).toBe(true); expect(faceAiIdentityCookie.secure).toBe(true); 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); }); \ No newline at end of file diff --git a/faceai/tests/live-site/live-site-test-utils.js b/faceai/tests/live-site/live-site-test-utils.js index f7f99061..c98819d7 100644 --- a/faceai/tests/live-site/live-site-test-utils.js +++ b/faceai/tests/live-site/live-site-test-utils.js @@ -2,11 +2,15 @@ const fs = require('fs'); const path = require('path'); 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_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_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_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'); function ensureAuthDirectory() { @@ -75,18 +79,45 @@ async function expectRacePageLoaded(page) { 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 = { AUTH_FILE, + LIVE_FACEAI_BASE_URL, LIVE_SITE_BASE_URL, LIVE_SITE_LOGIN_URL, LIVE_SITE_PASSWORD, + LIVE_SITE_PORTRAIT_PATH, LIVE_SITE_RACE_URL, + LIVE_SITE_RUN_UPLOAD_FLOW, LIVE_SITE_USERNAME, dismissCookieBanner, + ensureLiveAuthenticatedRacePage, ensureAuthDirectory, expectRacePageLoaded, loginSubmitLocator, performLiveLogin, + requirePortraitFixture, requireCredentials, waitForLoggedInUi }; \ No newline at end of file diff --git a/test_pkl/live/test_portrait_1.png b/test_pkl/live/test_portrait_1.png new file mode 100644 index 00000000..48e5c58c Binary files /dev/null and b/test_pkl/live/test_portrait_1.png differ diff --git a/www/_js/rus-ecom-240621.js b/www/_js/rus-ecom-240621.js index d6f5b561..c83fbbe6 100644 --- a/www/_js/rus-ecom-240621.js +++ b/www/_js/rus-ecom-240621.js @@ -258,6 +258,7 @@ function buildFaceAiLaunchUrl() { var raceYear = getFaceAiStorageValue("faceAiRaceYear", "year"); var raceMonthFolder = getFaceAiStorageValue("faceAiRaceMonthFolder", "monthFolder"); var raceFolder = getFaceAiStorageValue("faceAiRaceFolder", "raceFolder"); + var raceStorageRelativeDir = $("#faceAiRaceStorageRelativeDir").val() || [raceYear, raceMonthFolder, raceFolder].filter(Boolean).join("/"); var lang = getCurrentLangValue(); var handoffUrl = (window.faceAiSimulator && window.faceAiSimulator.handoffUrl) || "faceai_handoff.php"; var returnUrl = (window.faceAiSimulator && window.faceAiSimulator.returnUrl) || window.location.href; @@ -268,6 +269,7 @@ function buildFaceAiLaunchUrl() { "raceYear=" + encodeURIComponent(raceYear), "raceMonthFolder=" + encodeURIComponent(raceMonthFolder), "raceFolder=" + encodeURIComponent(raceFolder), + "raceStorageRelativeDir=" + encodeURIComponent(raceStorageRelativeDir), "lang=" + encodeURIComponent(lang), "returnUrl=" + encodeURIComponent(returnUrl) ]; diff --git a/www/faceai_handoff.php b/www/faceai_handoff.php index 466af2d2..08767ccb 100644 --- a/www/faceai_handoff.php +++ b/www/faceai_handoff.php @@ -11,6 +11,7 @@ try { $raceYear = faceai_request_value('raceYear'); $raceMonthFolder = faceai_request_value('raceMonthFolder'); $raceFolder = faceai_request_value('raceFolder'); + $raceStorageRelativeDir = faceai_request_value('raceStorageRelativeDir'); $lang = faceai_request_value('lang', 'it'); $returnUrl = faceai_request_value('returnUrl'); @@ -45,11 +46,12 @@ try { 'name' => $raceName !== '' ? $raceName : $raceId ); - if ($raceYear !== '' && $raceMonthFolder !== '' && $raceFolder !== '') { + if ($raceYear !== '' || $raceMonthFolder !== '' || $raceFolder !== '' || $raceStorageRelativeDir !== '') { $racePayload['storage'] = array( 'year' => $raceYear, 'monthFolder' => $raceMonthFolder, - 'raceFolder' => strtoupper(trim($raceFolder)) + 'raceFolder' => strtoupper(trim($raceFolder)), + 'relativeDir' => $raceStorageRelativeDir ); }