End to end tests
This commit is contained in:
parent
c71e4b4cd0
commit
fed82d1ae8
26 changed files with 1016 additions and 37 deletions
399
faceai/tests/e2e/faceai-simulator.spec.js
Normal file
399
faceai/tests/e2e/faceai-simulator.spec.js
Normal 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);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue