Regalamiunsorriso/faceai/tests/e2e/faceai-simulator.spec.js
MaddoScientisto 4f003bb5a9
All checks were successful
Publish FaceAI Container / publish (push) Successful in 9m53s
feat: Add FaceAI handoff URL builder and enhance race storage metadata handling
2026-04-19 16:12:48 +02:00

475 lines
17 KiB
JavaScript

const { test, expect } = require('@playwright/test');
const {
EXPECTED_MATCH_COUNT,
FACEAI_BASE_URL,
buildHandoffUrl,
buildSimulatorUrl,
getSearchArtifacts,
getSelfiePath,
readUtf8
} = require('./faceai-test-utils');
const FACEAI_HOME_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):3001\/?(?:\?.*)?$/;
const FACEAI_CALLBACK_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):3001\/auth\/callback\?token=/;
const 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}.*`);
}
function assertLogDoesNotContain(content, patterns, label) {
for (const pattern of patterns) {
expect(content, `${label} should not contain ${pattern}`).not.toMatch(pattern);
}
}
async function waitForFaceAiHome(page) {
await page.waitForURL((url) => FACEAI_CALLBACK_URL_RE.test(url.toString()) || FACEAI_HOME_URL_RE.test(url.toString()), {
timeout: SHORT_UI_TIMEOUT_MS
});
await expect(page.getByRole('heading', { name: 'Trova le tue foto con un selfie' })).toBeVisible();
}
async function launchFromSimulator(page, options = {}) {
const simulatorUrl = buildSimulatorUrl(options);
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
await expect(page.locator('select#tipoPuntoFoto')).toHaveCount(0);
await page.locator('#faceaiLaunchButton').click();
await waitForFaceAiHome(page);
return simulatorUrl;
}
async function enterViaHandoff(page, options = {}) {
await page.goto(buildHandoffUrl(options), { waitUntil: 'domcontentloaded' });
await waitForFaceAiHome(page);
}
async function readLaunchUrlFromLegacyPage(page) {
const launchUrl = await page.evaluate(() => {
return typeof buildFaceAiLaunchUrl === 'function' ? buildFaceAiLaunchUrl() : '';
});
expect(launchUrl, 'Expected the simulator race page to expose a FaceAI handoff URL builder.').toBeTruthy();
return new URL(launchUrl, 'http://127.0.0.1:8080');
}
async function startSearch(page, selfieName) {
const createResponsePromise = page.waitForResponse((response) => {
return response.url().includes('/api/searches')
&& response.request().method() === 'POST'
&& response.status() === 201;
});
await page.locator('input[type="file"]').setInputFiles(getSelfiePath(selfieName));
await expect(page.getByText(selfieName)).toBeVisible();
await page.getByRole('button', { name: 'Avvia ricerca Face ID' }).click();
const createResponse = await createResponsePromise;
return createResponse.json();
}
async function fetchSearchStatus(page, searchId) {
return page.evaluate(async ({ searchId }) => {
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
const body = await response.json().catch(() => null);
return {
statusCode: response.status,
body
};
}, { searchId });
}
async function waitForSearchCondition(page, searchId, predicate, timeoutMs = 30 * 1000) {
const deadline = Date.now() + timeoutMs;
let lastPayload = null;
while (Date.now() < deadline) {
const payload = await fetchSearchStatus(page, searchId);
lastPayload = payload;
if (payload.statusCode === 200 && predicate(payload.body)) {
return payload.body;
}
await page.waitForTimeout(250);
}
throw new Error(`Timed out waiting for search ${searchId}. Last payload: ${JSON.stringify(lastPayload)}`);
}
async function waitForLegacyResult(page, raceId, expectedMatchCount = null) {
await page.waitForURL(buildLegacySimulatorReturnMatcher(raceId), {
timeout: LEGACY_RETURN_TIMEOUT_MS,
waitUntil: 'commit'
});
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) {
return;
}
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 }) {
const artifacts = getSearchArtifacts(searchId);
const [backendLog, processorLog, workerLog, matcherLog] = await Promise.all([
readUtf8(artifacts.backendLogPath),
readUtf8(artifacts.processorLogPath),
readUtf8(artifacts.workerLogPath),
readUtf8(artifacts.matcherLogPath)
]);
expect(workerLog).toContain('Completed FaceAI search');
if (expectedMatchCount !== undefined) {
expect(workerLog).toContain(`"matchCount":${expectedMatchCount}`);
}
if (expectedSelfieName) {
expect(matcherLog).toContain(expectedSelfieName);
}
assertLogDoesNotContain(backendLog, [/\bnpm error\b/i, /\berror:\b/i, /\bfailed\b/i], 'backend.log');
assertLogDoesNotContain(processorLog, [new RegExp(`Failed FaceAI search ${searchId}`, 'i'), /\bnpm error\b/i], 'processor.log');
assertLogDoesNotContain(workerLog, [/FaceAI search failed/i], 'worker.log');
assertLogDoesNotContain(matcherLog, [/\[ERROR\]/i, /Traceback/i], 'matcher.log');
return { backendLog, processorLog, workerLog, matcherLog };
}
async function closeContexts(contexts) {
await Promise.all(contexts.map(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',
raceName: 'Mezza di Pisa',
raceFolder: 'PISA'
});
const search = await startSearch(page, 'DSC_1960.JPG');
await waitForLegacyResult(page, '202', EXPECTED_MATCH_COUNT);
await verifySearchLogs(search.id, {
expectedMatchCount: EXPECTED_MATCH_COUNT,
expectedSelfieName: 'DSC_1960.JPG'
});
});
test('builds the simulator FaceAI handoff URL with the exact local race storage metadata', async ({ page }) => {
await page.goto(buildSimulatorUrl({
raceId: '202',
raceSlug: 'mezza-di-pisa',
raceName: 'Mezza di Pisa',
raceYear: '2026',
raceMonthFolder: '04.APRILE',
raceFolder: 'PISA'
}), { waitUntil: 'domcontentloaded' });
await expect(page.locator('#faceAiRaceYear')).toHaveValue('2026');
await expect(page.locator('#faceAiRaceMonthFolder')).toHaveValue('04.APRILE');
await expect(page.locator('#faceAiRaceFolder')).toHaveValue('PISA');
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('PISA');
expect(launchUrl.searchParams.get('raceStorageRelativeDir')).toBe('2026/04.APRILE/PISA');
});
test('shows the unsupported-race message when the current race has no PKL data and lets the user go back', async ({ page }) => {
await launchFromSimulator(page, {
raceId: '404',
raceSlug: 'corsa-di-livorno',
raceName: 'Corsa di Livorno',
raceFolder: 'LIVORNO'
});
await expect(page.locator('.faceai-feedback')).toContainText('FaceAI non è disponibile per questa gara.');
await expect(page.locator('input[type="file"]')).toBeDisabled();
await expect(page.getByRole('button', { name: 'Scegli immagine' })).toBeDisabled();
await page.waitForTimeout(2000);
await expect(page).toHaveURL(FACEAI_HOME_URL_RE);
await page.getByRole('button', { name: 'Torna alla pagina gara' }).click();
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('404'));
});
test('shows a localized invalid-race error when session race data points to a missing folder', async ({ page }) => {
const consoleErrors = [];
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
const simulatorUrl = buildSimulatorUrl({
raceId: '405',
lang: 'en',
raceSlug: 'ghost-race',
raceName: 'Ghost Race',
raceFolder: 'THIS RACE DOES NOT EXIST'
});
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
await page.locator('#faceaiLaunchButton').click();
await page.waitForURL(FACEAI_HOME_URL_RE, {
timeout: 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.');
await expect(page.locator('input[type="file"]')).toBeDisabled();
await expect(page.getByRole('button', { name: 'Choose image' })).toBeDisabled();
await expect(page.getByRole('button', { name: 'Start Face ID search' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Back to the race page' })).toBeVisible();
await expect.poll(() => {
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
}).toContain('RACE_DIRECTORY_NOT_FOUND');
await expect.poll(() => {
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
}).toContain('THIS RACE DOES NOT EXIST');
await page.getByRole('button', { name: 'Back to the race page' }).click();
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('405'));
});
test('rejects a not-logged-in user after clicking the Face ID button and sends them back to the original race page', async ({ page }) => {
const simulatorUrl = buildSimulatorUrl({
raceId: '202',
raceSlug: 'mezza-di-pisa',
raceName: 'Mezza di Pisa',
raceFolder: 'PISA'
});
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
await page.evaluate(() => {
if (window.faceAiSimulator) {
delete window.faceAiSimulator.devUserId;
delete window.faceAiSimulator.devDisplayName;
delete window.faceAiSimulator.devEmail;
delete window.faceAiSimulator.devMembershipStatus;
}
});
await page.locator('#faceaiLaunchButton').click();
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('202'));
await expect(page).not.toHaveURL(FACEAI_HOME_URL_RE);
await expect(page.locator('#faceAiErrorModal')).toBeVisible();
await expect(page.locator('#faceAiErrorModalLabel')).toContainText('Face ID non disponibile');
await expect(page.locator('#faceAiErrorModalMessage')).toContainText('Il servizio Face ID non e al momento disponibile. Riprova piu tardi.');
});
test('shows the no-face message and allows the user to return to the race page', async ({ page }) => {
test.slow();
await launchFromSimulator(page, {
raceId: '202',
raceSlug: 'mezza-di-pisa',
raceName: 'Mezza di Pisa',
raceFolder: 'PISA'
});
const search = await startSearch(page, 'DSC_1994.JPG');
await waitForSearchCondition(page, search.id, (payload) => {
return payload.status === 'completed' && payload.completionCode === 'NO_FACES_FOUND';
}, SEARCH_COMPLETION_TIMEOUT_MS);
await expect(page.locator('.faceai-feedback')).toContainText('Nessun volto rilevato nella foto caricata');
await page.waitForTimeout(2000);
await expect(page).toHaveURL(FACEAI_HOME_URL_RE);
await verifySearchLogs(search.id, {
expectedMatchCount: 0,
expectedSelfieName: 'DSC_1994.JPG'
});
await page.getByRole('button', { name: 'Torna alla pagina gara' }).click();
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',
raceName: 'Mezza di Pisa',
raceFolder: 'PISA'
});
const noFaceSearch = await startSearch(page, 'DSC_1994.JPG');
await waitForSearchCondition(page, noFaceSearch.id, (payload) => {
return payload.status === 'completed' && payload.completionCode === 'NO_FACES_FOUND';
}, 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, '202', EXPECTED_MATCH_COUNT);
await verifySearchLogs(noFaceSearch.id, {
expectedMatchCount: 0,
expectedSelfieName: 'DSC_1994.JPG'
});
await verifySearchLogs(retrySearch.id, {
expectedMatchCount: EXPECTED_MATCH_COUNT,
expectedSelfieName: 'DSC_1960.JPG'
});
});
test('redirects direct-entry users without FaceAI session data back to the legacy site', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto(`${FACEAI_BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await page.waitForURL(LEGACY_HOME_URL_RE, { timeout: 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()));
try {
await Promise.all([
enterViaHandoff(pages[0], { userId: 'concurrency-user-1' }),
enterViaHandoff(pages[1], { userId: 'concurrency-user-2' })
]);
const [searchOne, searchTwo] = await Promise.all([
startSearch(pages[0], 'DSC_1960.JPG'),
startSearch(pages[1], 'DSC_1987.JPG')
]);
await Promise.all([
waitForLegacyResult(pages[0], '202'),
waitForLegacyResult(pages[1], '202')
]);
await Promise.all([
verifySearchLogs(searchOne.id, { expectedSelfieName: 'DSC_1960.JPG' }),
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' })
]);
} finally {
await closeContexts(contexts);
}
});
test('queues the third user until a worker is free and then completes all three searches normally', async ({ browser }) => {
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()));
try {
await Promise.all([
enterViaHandoff(pages[0], { userId: 'queue-user-1' }),
enterViaHandoff(pages[1], { userId: 'queue-user-2' }),
enterViaHandoff(pages[2], { userId: 'queue-user-3' })
]);
const [searchOne, searchTwo, searchThree] = await Promise.all([
startSearch(pages[0], 'DSC_1960.JPG'),
startSearch(pages[1], 'DSC_1987.JPG'),
startSearch(pages[2], 'DSC_2058.JPG')
]);
const searchSessions = [
{ page: pages[0], searchId: searchOne.id },
{ page: pages[1], searchId: searchTwo.id },
{ page: pages[2], searchId: searchThree.id }
];
let queuedSearch = null;
const deadline = Date.now() + 30 * 1000;
while (Date.now() < deadline && !queuedSearch) {
const statuses = await Promise.all(searchSessions.map(async (session) => {
const payload = await fetchSearchStatus(session.page, session.searchId);
return {
...session,
search: payload.body
};
}));
const processingCount = statuses.filter((item) => item.search?.status === 'processing').length;
queuedSearch = processingCount >= 2
? statuses.find((item) => item.search?.status === 'queued') || null
: null;
if (!queuedSearch) {
await pages[0].waitForTimeout(250);
}
}
expect(queuedSearch, 'one search should remain queued while two worker slots are busy').toBeTruthy();
await waitForSearchCondition(queuedSearch.page, queuedSearch.searchId, (payload) => {
return payload.status === 'processing' || payload.status === 'completed';
}, SEARCH_COMPLETION_TIMEOUT_MS);
await Promise.all(pages.map((page) => waitForLegacyResult(page, '202')));
await Promise.all([
verifySearchLogs(searchOne.id, { expectedSelfieName: 'DSC_1960.JPG' }),
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' }),
verifySearchLogs(searchThree.id, { expectedSelfieName: 'DSC_2058.JPG' })
]);
} finally {
await closeContexts(contexts);
}
});