diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..c92af18f --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,2 @@ +# Ignore temporary pid files +*.pid \ No newline at end of file diff --git a/e2e/application.ts b/e2e/application.ts new file mode 100644 index 00000000..fd78fb3b --- /dev/null +++ b/e2e/application.ts @@ -0,0 +1,119 @@ +import { spawn } from "child_process"; +import { Process } from "e2e/process"; +import * as http from 'http'; + +export const API_PID_FILE = 'api.pid'; +export const CLIENT_PID_FILE = 'client.pid'; +export const DEFAULT_START_TIMEOUT = 120_000; +export const API_URL = 'http://localhost:4000'; +export const CLIENT_URL = 'http://localhost:3000'; + +const waitUntilServerIsUp = (url: string) => { + return new Promise((resolve, reject) => { + const check = () => { + http.get(url, (res) => { + if (res.statusCode === 200) { + resolve(); + } else { + setTimeout(check, 300); + } + }).on('error', () => { + setTimeout(check, 300); + }); + }; + check(); + }); +}; + +const waitUntilServerIsDown = (url: string) => { + return new Promise((resolve) => { + const check = () => { + http.get(url, () => { + console.log(`Server is still up. Waiting for it to shutdown... ${url}`); + setTimeout(check, 300); + }).on('error', () => { + resolve(); + }); + }; + check(); + }); +}; + +const startClientServer = async () => { + const currentPid = Process.getPid(CLIENT_PID_FILE); + if(currentPid !== undefined) return; + + const { pid } = spawn('NODE_ENV=test pnpm --filter client run build && NODE_ENV=test pnpm --filter client run start', { + env: { ...process.env, NODE_ENV: 'test' }, + stdio: 'ignore', + detached: true, + shell: true + }); + Process.setPid(CLIENT_PID_FILE, pid); + await waitUntilServerIsUp(CLIENT_URL); +} + +const stopClientServer = async () => { + const currentPid = Process.getPid(CLIENT_PID_FILE); + if(currentPid === undefined) return; + + Process.kill(-currentPid); + Process.setPid(CLIENT_PID_FILE, undefined); +} + +async function startAPIServer() { + const currentPid = Process.getPid(API_PID_FILE); + if(currentPid !== undefined) return; + + const { pid } = spawn('pnpm --filter api run start:prod', { + env: { ...process.env, NODE_ENV: 'test' }, + stdio: 'ignore', + detached: true, + shell: true + }); + Process.setPid(API_PID_FILE, pid); + await waitUntilServerIsUp(API_URL); +} + +async function stopAPIServer() { + const currentPid = Process.getPid(API_PID_FILE); + if(currentPid === undefined) return; + + Process.kill(-currentPid); + Process.setPid(API_PID_FILE, undefined); + await waitUntilServerIsDown(API_URL); +} + +export const Application = { + CLIENT_URL, + API_URL, + startClientServer, + stopClientServer, + startAPIServer, + stopAPIServer, + globalSetup: async () => { + Process.setPid(API_PID_FILE, undefined); + Process.setPid(CLIENT_PID_FILE, undefined); + + await Promise.race([ + Promise.all([ + Application.startAPIServer(), + Application.startClientServer() + ]), + new Promise((_, reject) => setTimeout(() => { + reject(new Error('globalSetup timed out')) + }, DEFAULT_START_TIMEOUT)), + ]) + }, + globalTeardown: async () => { + await Promise.race([ + Promise.all([ + Application.stopAPIServer(), + Application.stopClientServer() + ]), + new Promise((_, reject) => setTimeout(() => { + reject(new Error('globalTeardown timed out')) + }, DEFAULT_START_TIMEOUT)), + ]) + } +} as const; \ No newline at end of file diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 00000000..9648d91a --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,3 @@ +import { Application } from "e2e/application"; + +export default Application.globalSetup; \ No newline at end of file diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 00000000..f931fa93 --- /dev/null +++ b/e2e/global-teardown.ts @@ -0,0 +1,3 @@ +import { Application } from "e2e/application"; + +export default Application.globalTeardown; \ No newline at end of file diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 2aa2430d..7db17bbe 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,27 +1,13 @@ import { defineConfig, devices } from '@playwright/test'; - -const API_URL = 'http://localhost:4000'; -const APP_URL = 'http://localhost:3000'; +import { ChildProcess, spawn } from 'child_process'; +import { Application } from 'e2e/application'; /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - /* Run your local dev server before starting the tests */ - webServer: [ - { - command: 'pnpm --filter api run build && NODE_ENV=test pnpm --filter api run start:prod', - url: API_URL, - reuseExistingServer: !process.env.CI, - timeout: 120_000 - }, - { - command: 'NODE_ENV=test pnpm --filter client run build && NODE_ENV=test pnpm --filter client run start', - url: APP_URL, - reuseExistingServer: !process.env.CI, - timeout: 120_000 - }, - ], + globalSetup: require.resolve('./global-setup'), + globalTeardown: require.resolve('./global-teardown'), testDir: './tests', /* Run tests in files in parallel */ fullyParallel: false, @@ -36,7 +22,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: APP_URL, + baseURL: Application.CLIENT_URL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', }, diff --git a/e2e/process.ts b/e2e/process.ts new file mode 100644 index 00000000..33d432c9 --- /dev/null +++ b/e2e/process.ts @@ -0,0 +1,28 @@ +import * as fs from 'fs'; + +export const Process = { + setPid: (path: string, pid?: number) => { + if(pid === undefined) { + if (fs.existsSync(path) === true) { + fs.unlinkSync(path); + } + return; + } + fs.writeFileSync(path, pid.toString(), 'utf8'); + }, + getPid: (path: string) => { + if (fs.existsSync(path) === false) { + return undefined; + } + return parseInt(fs.readFileSync(path, 'utf8')); + }, + kill: (pid: number, signal: NodeJS.Signals = 'SIGKILL') => { + try { + process.kill(pid, signal); + } catch (error) { + if (error.code !== 'ESRCH') { + throw error; + } + } + } + } as const; \ No newline at end of file diff --git a/e2e/tests/auth/auth.spec.ts b/e2e/tests/auth/auth.spec.ts index d76bb89c..f64ffcdc 100644 --- a/e2e/tests/auth/auth.spec.ts +++ b/e2e/tests/auth/auth.spec.ts @@ -37,4 +37,15 @@ test.describe('Authentication', () => { await page.waitForURL('/profile'); await expect(page.locator('input[type="email"]')).toHaveValue(user.email); }); + + test('a user tries to sign in with invalid credentials', async ({ page }) => { + await page.goto('/auth/signin'); + + await page.getByPlaceholder('Enter your email').fill('invalid@email.com'); + await page.getByPlaceholder('*******').fill('12345678'); + await page.getByRole('button', { name: 'Log in' }).click(); + + const errorMessage = page.getByText('Invalid credentials'); + await expect(errorMessage).toBeVisible(); + }); }); diff --git a/e2e/tests/health/health.spec.ts b/e2e/tests/health/health.spec.ts new file mode 100644 index 00000000..c200698e --- /dev/null +++ b/e2e/tests/health/health.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; +import { E2eTestManager } from '@shared/lib/e2e-test-manager'; +import { Application } from 'e2e/application'; + +test.describe('Health', () => { + let testManager: E2eTestManager; + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + testManager = await E2eTestManager.load(page); + }); + + test.afterAll(async () => { + await testManager.close(); + }); + + test.describe('when the backend is up', () => { + test('an anonymous user tries to check the application\'s health (both frontend and backend)', async ({ page }) => { + // Given that backend application was succesfully started + // App already started in globalSetup. Just being explicit here. + await Application.startAPIServer(); + + // When + const response = await page.goto('/health'); + + // Then + expect(response?.status()).toBe(200); + await expect(page.getByText('OK')).toBeVisible(); + }); + }) + + test.describe('when the backend is down', () => { + test.afterAll(async () => { + await Application.startAPIServer(); + }) + + test('an anonymous user tries to check the application\'s health (both frontend and backend)', async ({ page }) => { + // Given that the backend app is unavailable + await Application.stopAPIServer(); + + // When + const response = await page.goto('/health'); + + // Then + expect(response?.status()).toBe(503); + await expect(page.getByText('KO')).toBeVisible(); + }); + }) +});