End to end tests

This commit is contained in:
MaddoScientisto 2026-04-12 19:31:12 +02:00
commit fed82d1ae8
26 changed files with 1016 additions and 37 deletions

View file

@ -0,0 +1,399 @@
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 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$/;
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: 60 * 1000
});
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 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, expectedMatchCount = null) {
await page.waitForURL(FACEAI_RETURN_URL_RE, {
timeout: 6 * 60 * 1000
});
await expect(page.locator('.sim-banner')).toContainText('Vista filtrata da FaceAI');
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);
}
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((context) => context.close()));
}
test('runs the simulator flow through FaceAI and returns to the filtered legacy result', async ({ page }) => {
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, EXPECTED_MATCH_COUNT);
await expect(page.locator('.gallery-card').filter({ hasText: 'DSC_1960.JPG' }).first()).toBeVisible();
await verifySearchLogs(search.id, {
expectedMatchCount: EXPECTED_MATCH_COUNT,
expectedSelfieName: 'DSC_1960.JPG'
});
});
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('link', { 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: 60 * 1000
});
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('link', { 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('link', { 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 }) => {
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';
}, 2 * 60 * 1000);
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('link', { name: 'Torna alla pagina gara' }).click();
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('202'));
});
test('lets the user retry with a valid photo after a no-face upload and then returns to the filtered legacy result', async ({ page }) => {
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';
}, 2 * 60 * 1000);
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 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: 30 * 1000 });
} finally {
await context.close();
}
});
test('allows two users to process different photos at the same time', async ({ browser }) => {
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]),
waitForLegacyResult(pages[1])
]);
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 }) => {
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';
}, 2 * 60 * 1000);
await Promise.all(pages.map((page) => waitForLegacyResult(page)));
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);
}
});

View file

@ -0,0 +1,203 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { spawn } = require('node:child_process');
const ROOT_DIR = path.resolve(__dirname, '..', '..');
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/faceai_simulator.php?raceId=202&lang=it';
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 EXPECTED_MATCH_COUNT = Number(process.env.FACEAI_E2E_EXPECTED_MATCH_COUNT || '6');
function quoteShellArg(value) {
if (!/[\s"]/u.test(value)) {
return value;
}
return `"${value.replace(/"/g, '\\"')}"`;
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function runCommand(command, args, options = {}) {
const { cwd = ROOT_DIR, allowFailure = false } = options;
const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command;
const useShell = process.platform === 'win32';
return new Promise((resolve, reject) => {
const child = useShell
? spawn([executable, ...args].map(quoteShellArg).join(' '), {
cwd,
env: process.env,
shell: true,
stdio: ['ignore', 'pipe', 'pipe']
})
: spawn(executable, args, {
cwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
const result = { code, stdout, stderr };
if (code === 0 || allowFailure) {
resolve(result);
return;
}
const error = new Error(`Command failed: ${executable} ${args.join(' ')}`);
error.result = result;
reject(error);
});
});
}
function dockerCompose(args, options) {
return runCommand('docker', ['compose', ...args], options);
}
async function prepareHostState() {
await fs.rm(LOG_ROOT, { recursive: true, force: true });
await fs.mkdir(LOG_ROOT, { recursive: true });
}
async function waitForHttp(url, validate, timeoutMs = 3 * 60 * 1000) {
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
const bodyText = await response.text();
let parsedBody = null;
try {
parsedBody = JSON.parse(bodyText);
} catch {
parsedBody = null;
}
if (validate({ response, bodyText, parsedBody })) {
return;
}
lastError = new Error(`Readiness check did not pass for ${url}.`);
} catch (error) {
lastError = error;
}
await sleep(1000);
}
throw lastError || new Error(`Timed out waiting for ${url}`);
}
function getSelfiePath(fileName = SELFIE_NAME) {
return path.join(WORKSPACE_ROOT, 'test_pkl', 'test_images', fileName);
}
function buildSimulatorUrl({
raceId = '202',
lang = 'it',
raceSlug = 'mezza-di-pisa',
raceName = 'Mezza di Pisa',
raceYear = '2026',
raceMonthFolder = '04.APRILE',
raceFolder = 'PISA'
} = {}) {
const url = new URL('/faceai_simulator.php', LEGACY_BASE_URL);
url.searchParams.set('raceId', raceId);
url.searchParams.set('lang', lang);
url.searchParams.set('raceSlug', raceSlug);
url.searchParams.set('raceName', raceName);
url.searchParams.set('raceYear', raceYear);
url.searchParams.set('raceMonthFolder', raceMonthFolder);
url.searchParams.set('raceFolder', raceFolder);
return url.toString();
}
function buildHandoffUrl({
raceId = '202',
lang = 'it',
raceSlug = 'mezza-di-pisa',
raceName = 'Mezza di Pisa',
raceYear = '2026',
raceMonthFolder = '04.APRILE',
raceFolder = 'PISA',
userId = '1',
displayName = `Local Test User ${userId}`,
email = `local-test-${userId}@example.invalid`,
membershipStatus = 'active',
returnUrl = buildSimulatorUrl({ raceId, lang, raceSlug, raceName, raceYear, raceMonthFolder, raceFolder })
} = {}) {
const url = new URL('/faceai_handoff.php', LEGACY_BASE_URL);
url.searchParams.set('raceId', raceId);
url.searchParams.set('raceSlug', raceSlug);
url.searchParams.set('raceName', raceName);
url.searchParams.set('raceYear', raceYear);
url.searchParams.set('raceMonthFolder', raceMonthFolder);
url.searchParams.set('raceFolder', raceFolder);
url.searchParams.set('lang', lang);
url.searchParams.set('returnUrl', returnUrl);
url.searchParams.set('devUserId', userId);
url.searchParams.set('devDisplayName', displayName);
url.searchParams.set('devEmail', email);
url.searchParams.set('devMembershipStatus', membershipStatus);
return url.toString();
}
function getSearchArtifacts(searchId) {
const searchRoot = path.join(SEARCH_LOG_ROOT, searchId);
return {
searchRoot,
backendLogPath: path.join(LOG_ROOT, 'backend.log'),
processorLogPath: path.join(LOG_ROOT, 'processor.log'),
workerLogPath: path.join(searchRoot, 'worker.log'),
matcherLogPath: path.join(searchRoot, 'matcher.log')
};
}
async function readUtf8(filePath) {
return fs.readFile(filePath, 'utf8');
}
module.exports = {
ROOT_DIR,
LOG_ROOT,
SEARCH_LOG_ROOT,
FACEAI_BASE_URL,
LEGACY_BASE_URL,
LEGACY_HOME_URL,
SIMULATOR_URL,
SELFIE_NAME,
EXPECTED_MATCH_COUNT,
buildHandoffUrl,
buildSimulatorUrl,
dockerCompose,
getSearchArtifacts,
getSelfiePath,
prepareHostState,
readUtf8,
runCommand,
waitForHttp
};

View file

@ -0,0 +1,27 @@
const {
FACEAI_BASE_URL,
SIMULATOR_URL,
dockerCompose,
prepareHostState,
runCommand,
waitForHttp
} = require('./faceai-test-utils');
module.exports = async () => {
await prepareHostState();
await dockerCompose(['down', '-v', '--remove-orphans'], { allowFailure: true });
await runCommand('npm', ['run', 'build']);
await dockerCompose(['up', '--build', '-d']);
await waitForHttp(`${FACEAI_BASE_URL}/health`, ({ response, parsedBody }) => {
return response.ok && parsedBody && parsedBody.ok === true;
});
await waitForHttp(`${FACEAI_BASE_URL}/api/health/queue`, ({ response, parsedBody }) => {
return response.ok && parsedBody && parsedBody.ok === true;
});
await waitForHttp(SIMULATOR_URL, ({ response, bodyText }) => {
return response.ok && bodyText.includes('FaceAI Legacy Simulator');
});
};

View file

@ -0,0 +1,9 @@
const { dockerCompose } = require('./faceai-test-utils');
module.exports = async () => {
if (process.env.FACEAI_E2E_KEEP_STACK === '1') {
return;
}
await dockerCompose(['down', '-v', '--remove-orphans'], { allowFailure: true });
};