feat: Enhance FaceAI functionality and improve login process
All checks were successful
Publish FaceAI Container / publish (push) Successful in 4m43s
All checks were successful
Publish FaceAI Container / publish (push) Successful in 4m43s
- Added a retry mechanism for page navigation in `live-site-test-utils.js` to handle transient errors during login. - Introduced a new function `performLiveLoginRequest` to handle login requests via API, improving the login flow. - Updated the login process to utilize the new API request method, ensuring a more robust authentication. - Implemented new utility functions in `rus-ecom-240621.js` for managing FaceAI state and filtering. - Created `faceai_photo_lookup.jsp` to handle photo lookups, returning JSON responses for better integration with the frontend. - Updated `faceai_return.php` to redirect users with appropriate parameters after FaceAI processing. - Modified `fotoCR.jsp` and `fotoCR-en.jsp` to include FaceAI photo IDs in the image elements for better tracking. - Enhanced the UI to display the number of matched photos dynamically based on FaceAI results.
This commit is contained in:
parent
6f191de115
commit
bba8026b7c
17 changed files with 1077 additions and 95 deletions
|
|
@ -28,7 +28,7 @@ export const config = {
|
|||
uploadRoot: process.env.FACEAI_UPLOAD_ROOT || path.join(process.env.FACEAI_RUNTIME_ROOT || '/data/runtime', 'uploads'),
|
||||
searchTtlSeconds: Number(process.env.FACEAI_SEARCH_TTL_SECONDS || 24 * 60 * 60),
|
||||
resultTtlSeconds: Number(process.env.FACEAI_RESULT_TTL_SECONDS || 24 * 60 * 60),
|
||||
processorHeartbeatGraceMs: Number(process.env.FACEAI_PROCESSOR_HEARTBEAT_GRACE_MS || 20 * 1000),
|
||||
processorHeartbeatGraceMs: Number(process.env.FACEAI_PROCESSOR_HEARTBEAT_GRACE_MS || 60 * 1000),
|
||||
rateLimitWindowSeconds: Number(process.env.FACEAI_RATE_LIMIT_WINDOW_SECONDS || 10 * 60),
|
||||
rateLimitMaxRequests: Number(process.env.FACEAI_RATE_LIMIT_MAX_REQUESTS || 5)
|
||||
rateLimitMaxRequests: Number(process.env.FACEAI_RATE_LIMIT_MAX_REQUESTS || 20)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
getSearchRecord,
|
||||
incrementRateLimit,
|
||||
markSearchFailed,
|
||||
releaseActiveSearchLock,
|
||||
saveSearchRecord
|
||||
} from './redis-store.js';
|
||||
import { getSearchQueue } from './queue.js';
|
||||
|
|
@ -126,6 +127,27 @@ async function failSearchIfProcessorUnavailable(search) {
|
|||
);
|
||||
}
|
||||
|
||||
async function resolveBlockingActiveSearch(userId) {
|
||||
const activeSearchId = await getActiveSearchId(redis, userId);
|
||||
if (!activeSearchId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingSearch = await getSearchRecord(redis, activeSearchId);
|
||||
if (!existingSearch) {
|
||||
await releaseActiveSearchLock(redis, userId, activeSearchId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSearch = await failSearchIfProcessorUnavailable(existingSearch);
|
||||
if (!normalizedSearch || normalizedSearch.status === 'completed' || normalizedSearch.status === 'failed') {
|
||||
await releaseActiveSearchLock(redis, userId, activeSearchId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizedSearch;
|
||||
}
|
||||
|
||||
function logHealthFailure(details) {
|
||||
const signature = JSON.stringify(details);
|
||||
if (signature === lastHealthFailureSignature) {
|
||||
|
|
@ -528,13 +550,14 @@ app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single(
|
|||
return;
|
||||
}
|
||||
|
||||
const activeSearchId = await getActiveSearchId(redis, userId);
|
||||
const activeSearch = await resolveBlockingActiveSearch(userId);
|
||||
|
||||
if (activeSearchId) {
|
||||
if (activeSearch) {
|
||||
res.status(409).json({
|
||||
error: 'There is already an operation being processed.',
|
||||
code: 'ACTIVE_SEARCH_EXISTS',
|
||||
activeSearchId
|
||||
activeSearchId: activeSearch.id,
|
||||
activeSearchStatus: activeSearch.status
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script setup>
|
||||
defineProps({
|
||||
const fileInputId = 'faceai-upload-input';
|
||||
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
|
|
@ -54,6 +56,10 @@ defineProps({
|
|||
}
|
||||
});
|
||||
|
||||
function assignFileInput(element) {
|
||||
props.fileInput.value = element;
|
||||
}
|
||||
|
||||
const emit = defineEmits([
|
||||
'open-file-picker',
|
||||
'file-change',
|
||||
|
|
@ -108,7 +114,8 @@ const emit = defineEmits([
|
|||
@drop="emit('drop', $event)"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
:ref="assignFileInput"
|
||||
:id="fileInputId"
|
||||
class="d-none"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
|
|
@ -141,9 +148,18 @@ const emit = defineEmits([
|
|||
</div>
|
||||
|
||||
<div class="faceai-dropzone-actions" @click.stop>
|
||||
<button class="btn btn-outline-warning" type="button" :disabled="!canPickFile" @click="emit('open-file-picker')">
|
||||
<label
|
||||
class="btn btn-outline-warning mb-0"
|
||||
:class="{ disabled: !canPickFile }"
|
||||
:for="canPickFile ? fileInputId : null"
|
||||
role="button"
|
||||
:aria-disabled="!canPickFile"
|
||||
tabindex="0"
|
||||
@keydown.enter.prevent="emit('open-file-picker')"
|
||||
@keydown.space.prevent="emit('open-file-picker')"
|
||||
>
|
||||
{{ selectedFile ? t('uploaderReplace') : t('uploaderBrowse') }}
|
||||
</button>
|
||||
</label>
|
||||
<button v-if="selectedFile" class="btn btn-link" type="button" @click="emit('clear-file')">
|
||||
{{ t('uploaderRemove') }}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ const copy = {
|
|||
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.',
|
||||
activeSearchExists: 'C\'e gia una ricerca in corso per questo utente. Attendi il completamento oppure ricarica la pagina.',
|
||||
rateLimited: 'Hai avviato troppe ricerche in poco tempo. Attendi un momento e riprova.',
|
||||
faceAiAlt: 'FaceAI',
|
||||
dropzoneDisabled: 'Il caricamento non è disponibile per questa gara.'
|
||||
},
|
||||
|
|
@ -101,11 +103,23 @@ const copy = {
|
|||
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.',
|
||||
activeSearchExists: 'There is already a search running for this user. Wait for it to finish or reload the page.',
|
||||
rateLimited: 'Too many searches were started in a short time. Please wait a moment and try again.',
|
||||
faceAiAlt: 'FaceAI',
|
||||
dropzoneDisabled: 'Upload is not available for this race.'
|
||||
}
|
||||
};
|
||||
|
||||
const knownServerCodes = {
|
||||
PROCESSOR_UNAVAILABLE: 'processorUnavailable',
|
||||
ACTIVE_SEARCH_EXISTS: 'activeSearchExists',
|
||||
RATE_LIMITED: 'rateLimited',
|
||||
MISSING_SELFIE: 'chooseSelfie',
|
||||
RACE_PKL_UNAVAILABLE: 'raceDataUnavailable',
|
||||
RACE_DIRECTORY_NOT_FOUND: 'invalidRaceData',
|
||||
MISSING_RACE_STORAGE: 'invalidRaceData'
|
||||
};
|
||||
|
||||
const knownServerMessages = {
|
||||
'No training dataset available for this race.': 'raceDataUnavailable',
|
||||
'FaceAI data is not available for this race.': 'raceDataUnavailable',
|
||||
|
|
@ -115,7 +129,9 @@ const knownServerMessages = {
|
|||
'FaceAI processor is temporarily unavailable. Please try again shortly.': 'processorUnavailable',
|
||||
'Unable to build return URL.': 'redirectError',
|
||||
'Unable to create the search.': 'searchCreateError',
|
||||
'Choose a selfie before starting the search.': 'chooseSelfie'
|
||||
'Choose a selfie before starting the search.': 'chooseSelfie',
|
||||
'There is already an operation being processed.': 'activeSearchExists',
|
||||
'Too many search attempts. Please try again later.': 'rateLimited'
|
||||
};
|
||||
|
||||
const simulatorUrl = legacyUrl('/faceai_simulator.php?raceId=101&lang=it');
|
||||
|
|
@ -178,6 +194,15 @@ export function useFaceAiHome() {
|
|||
return t(fallbackKey);
|
||||
}
|
||||
|
||||
function localizeServerError(payload, fallbackKey) {
|
||||
const mappedCodeKey = payload?.code ? knownServerCodes[payload.code] : null;
|
||||
if (mappedCodeKey) {
|
||||
return t(mappedCodeKey);
|
||||
}
|
||||
|
||||
return localizeServerMessage(payload?.error, fallbackKey);
|
||||
}
|
||||
|
||||
function getAvailabilityUserMessage(availability, fallbackKey = 'unavailableDefault') {
|
||||
if (isInvalidRaceAvailability(availability)) {
|
||||
return t('invalidRaceData');
|
||||
|
|
@ -424,7 +449,7 @@ export function useFaceAiHome() {
|
|||
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
errorMessage.value = localizeServerMessage(payload.error, 'pollError');
|
||||
errorMessage.value = localizeServerError(payload, 'pollError');
|
||||
isSubmitting.value = false;
|
||||
logFaceAiDebug('Search polling failed', { searchId, status: response.status, payload });
|
||||
return;
|
||||
|
|
@ -451,7 +476,7 @@ export function useFaceAiHome() {
|
|||
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
|
||||
const payload = await redirectResponse.json();
|
||||
if (!redirectResponse.ok) {
|
||||
errorMessage.value = localizeServerMessage(payload.error, 'redirectError');
|
||||
errorMessage.value = localizeServerError(payload, 'redirectError');
|
||||
logFaceAiDebug('Redirect build failed', { searchId, payload });
|
||||
return;
|
||||
}
|
||||
|
|
@ -498,7 +523,7 @@ export function useFaceAiHome() {
|
|||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
errorMessage.value = localizeServerMessage(payload.error, 'searchCreateError');
|
||||
errorMessage.value = localizeServerError(payload, 'searchCreateError');
|
||||
isSubmitting.value = false;
|
||||
logFaceAiDebug('Search creation failed', { status: response.status, payload });
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const config = {
|
|||
pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl',
|
||||
matcherBinary: process.env.FACEAI_MATCHER_BINARY || '/app/bin/face_matcher',
|
||||
processorHeartbeatIntervalMs: Number(process.env.FACEAI_PROCESSOR_HEARTBEAT_INTERVAL_MS || 5 * 1000),
|
||||
processorHeartbeatTtlSeconds: Number(process.env.FACEAI_PROCESSOR_HEARTBEAT_TTL_SECONDS || 20),
|
||||
processorHeartbeatTtlSeconds: Number(process.env.FACEAI_PROCESSOR_HEARTBEAT_TTL_SECONDS || 60),
|
||||
searchTtlSeconds: Number(process.env.FACEAI_SEARCH_TTL_SECONDS || 24 * 60 * 60),
|
||||
resultTtlSeconds: Number(process.env.FACEAI_RESULT_TTL_SECONDS || 24 * 60 * 60)
|
||||
};
|
||||
|
|
@ -2,9 +2,9 @@ const { defineConfig } = require('@playwright/test');
|
|||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 10 * 60 * 1000,
|
||||
timeout: 60 * 1000,
|
||||
expect: {
|
||||
timeout: 30 * 1000
|
||||
timeout: 15 * 1000
|
||||
},
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ const authFile = path.join(__dirname, 'tests/live-site/.auth/user.json');
|
|||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests/live-site',
|
||||
timeout: 2 * 60 * 1000,
|
||||
timeout: 60 * 1000,
|
||||
expect: {
|
||||
timeout: 20 * 1000
|
||||
timeout: 15 * 1000
|
||||
},
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,12 @@ const {
|
|||
|
||||
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$/;
|
||||
const LONG_TEST_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const SHORT_UI_TIMEOUT_MS = 30 * 1000;
|
||||
const SEARCH_COMPLETION_TIMEOUT_MS = 75 * 1000;
|
||||
const LEGACY_RETURN_TIMEOUT_MS = 75 * 1000;
|
||||
const FILE_CHOOSER_TIMEOUT_MS = 8 * 1000;
|
||||
|
||||
function buildLegacySimulatorReturnMatcher(raceId) {
|
||||
return new RegExp(`http:\\/\\/(localhost|127\\.0\\.0\\.1):8080\\/faceai_simulator\\.php\\?raceId=${raceId}.*`);
|
||||
|
|
@ -26,7 +30,7 @@ function assertLogDoesNotContain(content, patterns, label) {
|
|||
|
||||
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
|
||||
timeout: SHORT_UI_TIMEOUT_MS
|
||||
});
|
||||
await expect(page.getByRole('heading', { name: 'Trova le tue foto con un selfie' })).toBeVisible();
|
||||
}
|
||||
|
|
@ -89,18 +93,24 @@ async function waitForSearchCondition(page, searchId, predicate, timeoutMs = 30
|
|||
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
|
||||
async function waitForLegacyResult(page, raceId, expectedMatchCount = null) {
|
||||
await page.waitForURL(buildLegacySimulatorReturnMatcher(raceId), {
|
||||
timeout: LEGACY_RETURN_TIMEOUT_MS,
|
||||
waitUntil: 'commit'
|
||||
});
|
||||
await expect(page.locator('.sim-banner')).toContainText('Vista filtrata da FaceAI');
|
||||
|
||||
await expect.poll(() => page.url(), {
|
||||
timeout: 15 * 1000,
|
||||
message: 'Expected the legacy simulator return URL to include FaceAI filter parameters.'
|
||||
}).toMatch(/faceaiPhotoIds=/);
|
||||
|
||||
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);
|
||||
const finalUrl = new URL(page.url());
|
||||
const photoIds = (finalUrl.searchParams.get('faceaiPhotoIds') || '').split(',').map((value) => value.trim()).filter(Boolean);
|
||||
expect(photoIds.length).toBe(expectedMatchCount);
|
||||
}
|
||||
|
||||
async function verifySearchLogs(searchId, { expectedMatchCount, expectedSelfieName }) {
|
||||
|
|
@ -129,10 +139,20 @@ async function verifySearchLogs(searchId, { expectedMatchCount, expectedSelfieNa
|
|||
}
|
||||
|
||||
async function closeContexts(contexts) {
|
||||
await Promise.all(contexts.map((context) => context.close()));
|
||||
await Promise.all(contexts.map(async (context) => {
|
||||
try {
|
||||
await context.close();
|
||||
} catch (error) {
|
||||
if (!/ENOENT|Target page, context or browser has been closed/i.test(String(error))) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
test('runs the simulator flow through FaceAI and returns to the filtered legacy result', async ({ page }) => {
|
||||
test.slow();
|
||||
|
||||
await launchFromSimulator(page, {
|
||||
raceId: '202',
|
||||
raceSlug: 'mezza-di-pisa',
|
||||
|
|
@ -142,8 +162,7 @@ test('runs the simulator flow through FaceAI and returns to the filtered legacy
|
|||
|
||||
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 waitForLegacyResult(page, '202', EXPECTED_MATCH_COUNT);
|
||||
|
||||
await verifySearchLogs(search.id, {
|
||||
expectedMatchCount: EXPECTED_MATCH_COUNT,
|
||||
|
|
@ -191,7 +210,7 @@ test('shows a localized invalid-race error when session race data points to a mi
|
|||
await page.locator('#faceaiLaunchButton').click();
|
||||
|
||||
await page.waitForURL(FACEAI_HOME_URL_RE, {
|
||||
timeout: 60 * 1000
|
||||
timeout: SHORT_UI_TIMEOUT_MS
|
||||
});
|
||||
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.');
|
||||
|
|
@ -240,6 +259,8 @@ test('rejects a not-logged-in user after clicking the Face ID button and sends t
|
|||
});
|
||||
|
||||
test('shows the no-face message and allows the user to return to the race page', async ({ page }) => {
|
||||
test.slow();
|
||||
|
||||
await launchFromSimulator(page, {
|
||||
raceId: '202',
|
||||
raceSlug: 'mezza-di-pisa',
|
||||
|
|
@ -251,7 +272,7 @@ test('shows the no-face message and allows the user to return to the race page',
|
|||
|
||||
await waitForSearchCondition(page, search.id, (payload) => {
|
||||
return payload.status === 'completed' && payload.completionCode === 'NO_FACES_FOUND';
|
||||
}, 2 * 60 * 1000);
|
||||
}, SEARCH_COMPLETION_TIMEOUT_MS);
|
||||
|
||||
await expect(page.locator('.faceai-feedback')).toContainText('Nessun volto rilevato nella foto caricata');
|
||||
await page.waitForTimeout(2000);
|
||||
|
|
@ -266,7 +287,26 @@ test('shows the no-face message and allows the user to return to the race page',
|
|||
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('202'));
|
||||
});
|
||||
|
||||
test('opens the file chooser when the user clicks the upload button', async ({ page }) => {
|
||||
await launchFromSimulator(page, {
|
||||
raceId: '202',
|
||||
raceSlug: 'mezza-di-pisa',
|
||||
raceName: 'Mezza di Pisa',
|
||||
raceFolder: 'PISA'
|
||||
});
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser', { timeout: FILE_CHOOSER_TIMEOUT_MS });
|
||||
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 expect(page.getByRole('button', { name: 'Avvia ricerca Face ID' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('lets the user retry with a valid photo after a no-face upload and then returns to the filtered legacy result', async ({ page }) => {
|
||||
test.slow();
|
||||
|
||||
await launchFromSimulator(page, {
|
||||
raceId: '202',
|
||||
raceSlug: 'mezza-di-pisa',
|
||||
|
|
@ -277,13 +317,13 @@ test('lets the user retry with a valid photo after a no-face upload and then ret
|
|||
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);
|
||||
}, SEARCH_COMPLETION_TIMEOUT_MS);
|
||||
|
||||
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 waitForLegacyResult(page, '202', EXPECTED_MATCH_COUNT);
|
||||
|
||||
await verifySearchLogs(noFaceSearch.id, {
|
||||
expectedMatchCount: 0,
|
||||
|
|
@ -301,13 +341,16 @@ test('redirects direct-entry users without FaceAI session data back to the legac
|
|||
|
||||
try {
|
||||
await page.goto(`${FACEAI_BASE_URL}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL(LEGACY_HOME_URL_RE, { timeout: 30 * 1000 });
|
||||
await page.waitForURL(LEGACY_HOME_URL_RE, { timeout: SHORT_UI_TIMEOUT_MS });
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('allows two users to process different photos at the same time', async ({ browser }) => {
|
||||
test.slow();
|
||||
test.setTimeout(LONG_TEST_TIMEOUT_MS);
|
||||
|
||||
const contexts = [await browser.newContext(), await browser.newContext()];
|
||||
const pages = await Promise.all(contexts.map((context) => context.newPage()));
|
||||
|
||||
|
|
@ -323,8 +366,8 @@ test('allows two users to process different photos at the same time', async ({ b
|
|||
]);
|
||||
|
||||
await Promise.all([
|
||||
waitForLegacyResult(pages[0]),
|
||||
waitForLegacyResult(pages[1])
|
||||
waitForLegacyResult(pages[0], '202'),
|
||||
waitForLegacyResult(pages[1], '202')
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
|
|
@ -337,6 +380,9 @@ test('allows two users to process different photos at the same time', async ({ b
|
|||
});
|
||||
|
||||
test('queues the third user until a worker is free and then completes all three searches normally', async ({ browser }) => {
|
||||
test.slow();
|
||||
test.setTimeout(LONG_TEST_TIMEOUT_MS);
|
||||
|
||||
const contexts = [await browser.newContext(), await browser.newContext(), await browser.newContext()];
|
||||
const pages = await Promise.all(contexts.map((context) => context.newPage()));
|
||||
|
||||
|
|
@ -384,9 +430,9 @@ test('queues the third user until a worker is free and then completes all three
|
|||
|
||||
await waitForSearchCondition(queuedSearch.page, queuedSearch.searchId, (payload) => {
|
||||
return payload.status === 'processing' || payload.status === 'completed';
|
||||
}, 2 * 60 * 1000);
|
||||
}, SEARCH_COMPLETION_TIMEOUT_MS);
|
||||
|
||||
await Promise.all(pages.map((page) => waitForLegacyResult(page)));
|
||||
await Promise.all(pages.map((page) => waitForLegacyResult(page, '202')));
|
||||
|
||||
await Promise.all([
|
||||
verifySearchLogs(searchOne.id, { expectedSelfieName: 'DSC_1960.JPG' }),
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ const { test } = require('@playwright/test');
|
|||
const {
|
||||
AUTH_FILE,
|
||||
ensureAuthDirectory,
|
||||
performLiveLogin
|
||||
performLiveLoginRequest
|
||||
} = require('./live-site-test-utils');
|
||||
|
||||
test('authenticate against the live site', async ({ page }) => {
|
||||
test('authenticate against the live site', async ({ request }) => {
|
||||
ensureAuthDirectory();
|
||||
await performLiveLogin(page);
|
||||
await page.context().storageState({ path: AUTH_FILE });
|
||||
await performLiveLoginRequest(request);
|
||||
await request.storageState({ path: AUTH_FILE });
|
||||
});
|
||||
|
|
@ -4,7 +4,6 @@ const {
|
|||
LIVE_SITE_BASE_URL,
|
||||
LIVE_SITE_PORTRAIT_PATH,
|
||||
LIVE_SITE_RACE_URL,
|
||||
LIVE_SITE_RESULT_URL_PATTERN,
|
||||
LIVE_SITE_RUN_UPLOAD_FLOW,
|
||||
ensureLiveAuthenticatedRacePage,
|
||||
requirePortraitFixture
|
||||
|
|
@ -80,6 +79,86 @@ async function waitForSearchCompletion(page, searchId) {
|
|||
});
|
||||
}
|
||||
|
||||
async function readVisibleLegacyPhotoIds(page) {
|
||||
return page.locator('#demo [data-faceai-photo-id]').evaluateAll((elements) => {
|
||||
return elements.map((element) => String(element.getAttribute('data-faceai-photo-id') || '').trim()).filter(Boolean);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForVisibleLegacyPhotoIds(page, expectedCount) {
|
||||
await expect.poll(async () => {
|
||||
const visiblePhotoIds = await readVisibleLegacyPhotoIds(page);
|
||||
return visiblePhotoIds.length;
|
||||
}, {
|
||||
timeout: 30 * 1000,
|
||||
message: 'Expected the legacy FaceAI gallery to finish loading matched thumbnails.'
|
||||
}).toBe(expectedCount);
|
||||
|
||||
return readVisibleLegacyPhotoIds(page);
|
||||
}
|
||||
|
||||
async function waitForVisibleLegacyThumbs(page, expectedCount) {
|
||||
const thumbs = page.locator('#demo [data-faceai-photo-id] img.thumb');
|
||||
|
||||
await expect(thumbs).toHaveCount(expectedCount);
|
||||
await expect.poll(async () => {
|
||||
return thumbs.evaluateAll((elements) => {
|
||||
return elements.every((element) => {
|
||||
const src = String(element.getAttribute('src') || '').trim();
|
||||
return src.length > 0 && src.indexOf('_imgNotFound') === -1;
|
||||
});
|
||||
});
|
||||
}, {
|
||||
timeout: 30 * 1000,
|
||||
message: 'Expected the legacy FaceAI gallery to resolve real thumbnail image URLs.'
|
||||
}).toBe(true);
|
||||
|
||||
return thumbs;
|
||||
}
|
||||
|
||||
async function waitForLegacyFaceAiCount(page, expectedCount) {
|
||||
await expect.poll(async () => {
|
||||
return page.locator('#faceAiPhotoCountValue').textContent();
|
||||
}, {
|
||||
timeout: 30 * 1000,
|
||||
message: 'Expected the legacy FaceAI count to match the resolved gallery size.'
|
||||
}).toBe(String(expectedCount));
|
||||
}
|
||||
|
||||
async function expectLegacyFaceAiGalleryToRemainStable(page, expectedPhotoIds, holdMs = 4000) {
|
||||
await page.waitForTimeout(holdMs);
|
||||
|
||||
const visiblePhotoIds = await readVisibleLegacyPhotoIds(page);
|
||||
expect(visiblePhotoIds.sort()).toEqual(expectedPhotoIds.slice().sort());
|
||||
|
||||
await expect(page.locator('#demo [data-faceai-photo-id]')).toHaveCount(expectedPhotoIds.length);
|
||||
await waitForVisibleLegacyThumbs(page, expectedPhotoIds.length);
|
||||
await waitForLegacyFaceAiCount(page, expectedPhotoIds.length);
|
||||
}
|
||||
|
||||
test('renders the exact live FaceAI filtered sample URL with visible thumbnails', async ({ page }) => {
|
||||
const samplePhotoIds = [
|
||||
'00.PANORAMICA\\GIC_7918.JPG',
|
||||
'02.PARTENZA\\GIC_7918.JPG'
|
||||
];
|
||||
const sampleUrl = `${LIVE_SITE_BASE_URL}/42%20HALF%20MARATHON%20FIRENZE_gara-1018545---48-1.html?faceaiMatchSource=faceai&faceaiMatchCount=2&faceaiPhotoIds=${encodeURIComponent(samplePhotoIds.join(','))}`;
|
||||
|
||||
await ensureLiveAuthenticatedRacePage(page);
|
||||
await page.goto(sampleUrl, {
|
||||
waitUntil: 'domcontentloaded'
|
||||
});
|
||||
|
||||
await expect(page.locator('form[onsubmit="return searching()"]')).toBeVisible();
|
||||
await expect(page.locator('#faceAiFilterBanner')).toContainText(/Face ID filter active|Filtro Face ID attivo/i);
|
||||
|
||||
const visiblePhotoIds = await waitForVisibleLegacyPhotoIds(page, samplePhotoIds.length);
|
||||
expect(visiblePhotoIds.sort()).toEqual(samplePhotoIds.slice().sort());
|
||||
|
||||
await waitForVisibleLegacyThumbs(page, samplePhotoIds.length);
|
||||
await waitForLegacyFaceAiCount(page, samplePhotoIds.length);
|
||||
await expectLegacyFaceAiGalleryToRemainStable(page, samplePhotoIds);
|
||||
});
|
||||
|
||||
test('loads a live race page with an authenticated session', async ({ page }) => {
|
||||
await ensureLiveAuthenticatedRacePage(page);
|
||||
|
||||
|
|
@ -129,7 +208,8 @@ test('returns to the live race page from FaceAI without leaving the legacy spinn
|
|||
await page.waitForURL((url) => {
|
||||
return url.toString().startsWith(LIVE_SITE_BASE_URL) && !url.toString().startsWith(LIVE_FACEAI_BASE_URL);
|
||||
}, {
|
||||
timeout: 60 * 1000
|
||||
timeout: 60 * 1000,
|
||||
waitUntil: 'commit'
|
||||
});
|
||||
|
||||
await expect(page.locator('form[onsubmit="return searching()"]')).toBeVisible();
|
||||
|
|
@ -149,6 +229,8 @@ test('returns to the live race page from FaceAI without leaving the legacy spinn
|
|||
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 }) => {
|
||||
test.slow();
|
||||
|
||||
requirePortraitFixture();
|
||||
|
||||
await openLiveFaceAi(page);
|
||||
|
|
@ -185,18 +267,24 @@ test('accepts the supplied portrait image for the live upload flow', async ({ pa
|
|||
|
||||
await waitForSearchCompletion(page, searchId);
|
||||
|
||||
await page.waitForURL(new RegExp(`^${escapeRegExp(LIVE_SITE_RESULT_URL_PATTERN)}`), {
|
||||
timeout: 3 * 60 * 1000
|
||||
});
|
||||
|
||||
await expect.poll(async () => page.url(), {
|
||||
timeout: 15 * 1000,
|
||||
message: 'Expected the browser to land on the legacy result page after FaceAI completed.'
|
||||
}).toMatch(new RegExp(`^${escapeRegExp(LIVE_SITE_BASE_URL)}/`));
|
||||
timeout: 30 * 1000,
|
||||
message: 'Expected the browser to land on the legacy race page with FaceAI filter parameters after FaceAI completed.'
|
||||
}).toMatch(new RegExp(`^${escapeRegExp(LIVE_SITE_BASE_URL)}/.*faceaiPhotoIds=`));
|
||||
|
||||
await expect(page.locator('body')).toContainText(/Vista filtrata da FaceAI|foto da FaceAI|ID foto:/i);
|
||||
await expect.poll(async () => page.locator('.gallery-card').count(), {
|
||||
timeout: 15 * 1000,
|
||||
message: 'Expected the legacy return page to render at least one FaceAI result card.'
|
||||
}).toBeGreaterThan(0);
|
||||
});
|
||||
await expect(page.locator('form[onsubmit="return searching()"]')).toBeVisible();
|
||||
await expect(page.locator('#faceAiFilterBanner')).toContainText(/Face ID filter active|Filtro Face ID attivo/i);
|
||||
await expect(page.locator('.gallery-card')).toHaveCount(0);
|
||||
|
||||
const finalUrl = new URL(page.url());
|
||||
const expectedPhotoIds = (finalUrl.searchParams.get('faceaiPhotoIds') || '').split(',').map((value) => value.trim()).filter(Boolean);
|
||||
expect(expectedPhotoIds.length, 'Expected the final race page URL to include at least one FaceAI photo identifier.').toBeGreaterThan(0);
|
||||
|
||||
const visiblePhotoIds = await waitForVisibleLegacyPhotoIds(page, expectedPhotoIds.length);
|
||||
expect(visiblePhotoIds.length, 'Expected at least one legacy race thumbnail to remain visible after FaceAI filtering.').toBeGreaterThan(0);
|
||||
expect(visiblePhotoIds.sort()).toEqual(expectedPhotoIds.slice().sort());
|
||||
|
||||
await waitForVisibleLegacyThumbs(page, expectedPhotoIds.length);
|
||||
await waitForLegacyFaceAiCount(page, expectedPhotoIds.length);
|
||||
await expectLegacyFaceAiGalleryToRemainStable(page, expectedPhotoIds);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,10 +41,39 @@ function loginSubmitLocator(page) {
|
|||
return page.locator('a.btn').filter({ hasText: /Accedi|Sign in/i }).first();
|
||||
}
|
||||
|
||||
function buildLoginFormData() {
|
||||
requireCredentials();
|
||||
return {
|
||||
login: LIVE_SITE_USERNAME,
|
||||
pwd: LIVE_SITE_PASSWORD,
|
||||
cmdIU: 'check',
|
||||
act: '',
|
||||
thePage: ''
|
||||
};
|
||||
}
|
||||
|
||||
async function gotoWithRetry(page, url, options = {}, maxAttempts = 3) {
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
await page.goto(url, options);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!/ERR_ABORTED/i.test(String(error)) || attempt === maxAttempts) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function performLiveLogin(page) {
|
||||
requireCredentials();
|
||||
|
||||
await page.goto(LIVE_SITE_LOGIN_URL, { waitUntil: 'domcontentloaded' });
|
||||
await gotoWithRetry(page, LIVE_SITE_LOGIN_URL, { waitUntil: 'commit' });
|
||||
await dismissCookieBanner(page);
|
||||
await page.locator('#login').fill(LIVE_SITE_USERNAME);
|
||||
await page.locator('#pwd').fill(LIVE_SITE_PASSWORD);
|
||||
|
|
@ -52,6 +81,24 @@ async function performLiveLogin(page) {
|
|||
await waitForLoggedInUi(page);
|
||||
}
|
||||
|
||||
async function performLiveLoginRequest(requestContext) {
|
||||
const response = await requestContext.post(`${LIVE_SITE_BASE_URL}/Logon.abl`, {
|
||||
form: buildLoginFormData(),
|
||||
failOnStatusCode: false
|
||||
});
|
||||
|
||||
const finalUrl = response.url();
|
||||
const bodyText = await response.text();
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Live 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(`Live login request appears to have remained on the login page at ${finalUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForLoggedInUi(page) {
|
||||
const accountMenu = page.locator('#navbarDropdownMenuLink');
|
||||
const accountLink = page.locator('a[href*="dettaglio_clienti"]');
|
||||
|
|
@ -81,14 +128,14 @@ async function expectRacePageLoaded(page) {
|
|||
}
|
||||
|
||||
async function ensureLiveAuthenticatedRacePage(page) {
|
||||
await page.goto(LIVE_SITE_RACE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await gotoWithRetry(page, LIVE_SITE_RACE_URL, { waitUntil: 'commit' });
|
||||
await dismissCookieBanner(page);
|
||||
|
||||
try {
|
||||
await waitForLoggedInUi(page);
|
||||
} catch (error) {
|
||||
await performLiveLogin(page);
|
||||
await page.goto(LIVE_SITE_RACE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await performLiveLoginRequest(page.context().request);
|
||||
await gotoWithRetry(page, LIVE_SITE_RACE_URL, { waitUntil: 'commit' });
|
||||
await dismissCookieBanner(page);
|
||||
await waitForLoggedInUi(page);
|
||||
}
|
||||
|
|
@ -119,6 +166,7 @@ module.exports = {
|
|||
expectRacePageLoaded,
|
||||
loginSubmitLocator,
|
||||
performLiveLogin,
|
||||
performLiveLoginRequest,
|
||||
requirePortraitFixture,
|
||||
requireCredentials,
|
||||
waitForLoggedInUi
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue