Some checks failed
Publish FaceAI Container / publish (push) Failing after 4s
399 lines
15 KiB
JavaScript
399 lines
15 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 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('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: 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('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 }) => {
|
|
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('button', { 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);
|
|
}
|
|
});
|