diff --git a/.gitignore b/.gitignore index a97e5eec68f8..9ad6dbd3146e 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,8 @@ apps/**/index.html .nx/ .zed/ + +# E2E outputs +test-results/ +playwright-report/ +tmp-sessions/ \ No newline at end of file diff --git a/libs/testing/e2e/README.md b/libs/testing/e2e/README.md index f5b4b36b7919..7fa5ac12b3db 100644 --- a/libs/testing/e2e/README.md +++ b/libs/testing/e2e/README.md @@ -1,7 +1,48 @@ -# libs/testing/e2e +# E2E Testing -This library was generated with [Nx](https://nx.dev). +This library was generated with [Nx](https://nx.dev). It contains utility functions and configuration files that assist with end-to-end (E2E) testing in Playwright for various apps. -## Running unit tests +## Overview -Run `nx test libs/testing/e2e` to execute the unit tests via [Jest](https://jestjs.io). +This library includes: + +- **Helper Functions:** Utility functions designed to streamline E2E testing with Playwright. These functions cater to different applications across the project and help automate common testing workflows. +- **Global Playwright Configuration:** The `createGlobalConfig` function provides a shared Playwright configuration used across multiple applications. It standardizes the testing environment. + +## Mockoon Usage Guide for E2E Tests + +This section explains how to use [Mockoon](https://mockoon.com/) to set up mock APIs for end-to-end (e2e) testing. + +### What is Mockoon? + +[Mockoon](https://mockoon.com/) is an open-source tool for creating mock APIs quickly and easily. It allows developers to simulate backend servers without relying on live backend services. This is especially useful for e2e testing, where consistency and repeatability of backend responses are important. + +Mockoon provides both a graphical user interface (GUI) for managing API mock files and a command-line interface (CLI) for running these mock APIs in various environments, such as pipelines. + +### Opening an Existing Mock File in Mockoon + +To view or modify an existing mock file: + +1. Open Mockoon. +2. Click on **+** and then click on **Open Local Environment**. +3. Choose the desired mock file, such as `apps//e2e/mocks/.json`. + +This will load the mock configuration into the Mockoon UI, allowing you to inspect and edit the mock endpoints. + +### Creating a Mock File with Mockoon UI + +To create or modify a mock file: + +1. Download and install [Mockoon](https://mockoon.com/download/) if you haven't already. +2. Open Mockoon and create a new environment: + - Click on **+** and then click on **New Local Environment**. + - Nema your mock file and choose a location for it e.g. `apps//e2e/mocks/.json`. + - Add endpoints, routes, and response details as needed. + +### Running a Mockoon Server with the CLI + +To run a mock server with the cli, use the following command: + +```bash +yarn mockoon-cli start --data ./apps//e2e/mocks/.json --port +``` diff --git a/libs/testing/e2e/src/index.ts b/libs/testing/e2e/src/index.ts index 88dfe575adfc..2964a0ea27c9 100644 --- a/libs/testing/e2e/src/index.ts +++ b/libs/testing/e2e/src/index.ts @@ -1 +1,13 @@ -export * from './lib/libs/testing/e2e' +export * from './lib/support/api-tools' +export * from './lib/support/application' +export * from './lib/support/disablers' +export * from './lib/support/email-account' +export * from './lib/support/i18n' +export * from './lib/support/locator-helpers' +export * from './lib/support/login' +export * from './lib/support/session' +export * from './lib/support/urls' +export * from './lib/support/utils' +export * from './lib/utils/pageHelpers' +export * from './lib/utils/playwright-config' +export { test, expect, Page, Locator, BrowserContext } from '@playwright/test' diff --git a/libs/testing/e2e/src/lib/config/playwright-config.ts b/libs/testing/e2e/src/lib/config/playwright-config.ts new file mode 100644 index 000000000000..ffd3b2c88fd3 --- /dev/null +++ b/libs/testing/e2e/src/lib/config/playwright-config.ts @@ -0,0 +1,54 @@ +import { defineConfig, devices } from '@playwright/test' + +interface GlobalConfigParams { + webServerUrl: string + port?: number + command: string + cwd?: string + timeoutMs?: number +} + +export const createGlobalConfig = ({ + webServerUrl, + port, + command, + cwd = '../../../', + timeoutMs = 5 * 60 * 1000, +}: GlobalConfigParams) => { + return defineConfig({ + testDir: 'e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + + use: { + baseURL: webServerUrl, + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + webServer: { + stdout: 'pipe', + port: port ? port : undefined, + url: port ? undefined : webServerUrl, + command, + reuseExistingServer: !process.env.CI, + timeout: timeoutMs, + cwd, + }, + }) +} diff --git a/libs/testing/e2e/src/lib/libs/testing/e2e.spec.ts b/libs/testing/e2e/src/lib/libs/testing/e2e.spec.ts deleted file mode 100644 index 64546b648ec3..000000000000 --- a/libs/testing/e2e/src/lib/libs/testing/e2e.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { libsTestingE2e } from './e2e' - -describe('libsTestingE2e', () => { - it('should work', () => { - expect(libsTestingE2e()).toEqual('libs/testing/e2e') - }) -}) diff --git a/libs/testing/e2e/src/lib/libs/testing/e2e.ts b/libs/testing/e2e/src/lib/libs/testing/e2e.ts deleted file mode 100644 index ead5b58b4769..000000000000 --- a/libs/testing/e2e/src/lib/libs/testing/e2e.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function libsTestingE2e(): string { - return 'libs/testing/e2e' -} diff --git a/libs/testing/e2e/src/lib/support/addons.ts b/libs/testing/e2e/src/lib/support/addons.ts new file mode 100644 index 000000000000..4c18dfd78c9e --- /dev/null +++ b/libs/testing/e2e/src/lib/support/addons.ts @@ -0,0 +1,50 @@ +import { expect, Locator, Page } from '@playwright/test' +import { sleep } from './utils' + +expect.extend({ + async toHaveCountGreaterThan( + received: Locator, + value: number, + options: { timeout: number; sleepTime: number } = { + timeout: 10000, + sleepTime: 100, + }, + ) { + const initialTime = Date.now() + let count = -1 + while (count <= value) { + count = await received.count() + if (Date.now() > initialTime + options.timeout) + return { + message: () => + `Timeout waiting for element count to exceed ${value}. Current count: ${count}`, + pass: false, + } + await sleep(options.sleepTime) + } + return { + message: () => `Found ${count} elements`, + pass: true, + } + }, + async toBeApplication( + received: string | Page, + applicationType = '[a-zA-Z0-9_-]+', + ) { + const url: string = typeof received == 'string' ? received : received.url() + const protocol = 'https?://' + const host = '[^/]+' + const applicationId = '(/[a-zA-Z0-9_-]*)?' + const applicationRegExp = new RegExp( + `^${protocol}${host}/umsoknir/${applicationType}${applicationId}$`, + ) + const pass = applicationRegExp.test(url) + const message = () => + [ + `Current page is ${pass ? '' : 'not '}an application`, + `Expected pattern: ${applicationRegExp}`, + `Actual URL: ${url}`, + ].join('\n') + return { message, pass } + }, +}) diff --git a/libs/testing/e2e/src/lib/support/api-tools.ts b/libs/testing/e2e/src/lib/support/api-tools.ts new file mode 100644 index 000000000000..af3b4cf1b339 --- /dev/null +++ b/libs/testing/e2e/src/lib/support/api-tools.ts @@ -0,0 +1,75 @@ +import { Page } from '@playwright/test' + +export const graphqlSpy = async ( + page: Page, + url: string, + operation: string, +) => { + const data: { + request: Record + response: Record + }[] = [] + await page.route(url, async (route, req) => { + const response = await page.request.fetch(req) + if ( + req.method() === 'POST' && + req.postDataJSON().operationName === operation + ) { + data.push({ + request: req.postDataJSON(), + response: await response.json(), + }) + } + await route.fulfill({ response }) + }) + return { + extractor: + ( + fieldExtractor: (op: { + request: Record + response: Record + }) => string, + ) => + () => { + const op = data[0] + return op ? fieldExtractor(op) : '' + }, + data: ( + fieldExtractor: (op: { + request: Record + response: unknown + }) => string, + ) => { + const op = data[0] + return op ? fieldExtractor(op) : '' + }, + } +} + +export const mockApi = async ( + page: Page, + url: string, + response: Record, +) => { + await page.route(url, async (route, _req) => { + await route.fulfill({ + status: 200, + body: JSON.stringify(response), + contentType: 'application/json', + }) + }) +} + +export const verifyRequestCompletion = async ( + page: Page, + url: string, + op: string, +) => { + const response = await page.waitForResponse( + (resp) => + resp.url().includes(url) && + resp.request().postDataJSON().operationName === op, + ) + + return await response.json() +} diff --git a/libs/testing/e2e/src/lib/support/application.ts b/libs/testing/e2e/src/lib/support/application.ts new file mode 100644 index 000000000000..41d6970650a4 --- /dev/null +++ b/libs/testing/e2e/src/lib/support/application.ts @@ -0,0 +1,29 @@ +import { Page } from '@playwright/test' + +/** + Creates a new application and returns the number of applications before creation. + @async + @function + @param {Page} page - Playwright Page object representing the current page. + @returns {Promise} - The number of applications before the new application is created. + This function waits for the applications to load on the overview page and + counts the number of applications. If there is an existing application, the + overview page will not redirect to a new application. In this case, the function + clicks the 'create-new-application' button to create a new application. + */ +export const createApplication = async (page: Page) => { + // Wait for the applications to load on the overview and count the number of applications + const responsePromise = await page.waitForResponse( + '**/api/graphql?op=ApplicationApplications', + ) + const response = await responsePromise + const responseData = await response.json() + const numberOfApplications = + responseData.data.applicationApplications.length || 0 + // if there is an application, the overview won't redirect to a new application and we need + // to click the button to create a new application + if (numberOfApplications > 0) { + await page.getByTestId('create-new-application').click() + } + return numberOfApplications +} diff --git a/libs/testing/e2e/src/lib/support/disablers.ts b/libs/testing/e2e/src/lib/support/disablers.ts new file mode 100644 index 000000000000..de5da1836ac8 --- /dev/null +++ b/libs/testing/e2e/src/lib/support/disablers.ts @@ -0,0 +1,185 @@ +import { Page } from '@playwright/test' +import mergeWith from 'lodash/merge' +import camelCase from 'lodash/camelCase' +import { debug } from './utils' + +const mergeOverwrite = (_: unknown, source: unknown): unknown => { + return source +} + +type Matchable = string | RegExp +type MockGQLOptions = { + responseKey?: string + camelCaseResponseKey?: boolean + patchResponse?: boolean + deepMockKey?: Matchable // TODO type for this: | Matchable[] + useResponseKey?: boolean + pattern?: string +} + +type Dict = Record +/** + * Return a copy of the `original` object with any sub-objects mocked as `mockData` + */ +const deepMock = ( + original: T | T[], + mockKey: Matchable, + mockData: unknown = {}, + { exactMatch = false, deepPath = 'data' } = {}, +): T | T[] | Dict | Dict[] => { + if (Array.isArray(original)) { + debug('Deep mocking array:', original) + // Should do the typing properly here :/ + return original.map( + (item: T) => deepMock(item, mockKey, mockData, { exactMatch }) as T, + ) + } + if (typeof original != 'object') { + return String(original).match(mockKey) ? (mockData as T) : original + } + + if (typeof mockKey == 'string') + mockKey = new RegExp(exactMatch ? `^${mockKey}$` : `${mockKey}`) + const mocked: Dict = {} + for (const key in original) { + if (key.match('currentLic')) debug('Mocking currentLic', original) + const updatedDeepPath = `${deepPath}.${key}` + if (key.match(mockKey)) { + mocked.isMocked = true + mocked[key] = mockData + debug(`Found deepMock match `, { + mockKey, + key, + updatedDeepPath, + mockData, + }) + } else + mocked[key] = deepMock(original[key], mockKey, mockData, { + deepPath: updatedDeepPath, + }) + } + if (mocked.isMocked) { + debug(`Deep mocking mocked data:`, mocked) + debug(`Deep mocking original data:`, original) + } + return mocked +} + +/** + * Mock any graphql operation, returning the given mockData + * + * Optionally, define a different data key in the response or turn off the default camelCasing + * of the operation. + */ +export const mockQGL = async ( + page: Page, + op: string, + mockData: T, + { + responseKey = undefined, + camelCaseResponseKey = !responseKey, + patchResponse = false, + deepMockKey = undefined, + pattern = `**/graphql?op=${op}`, + }: MockGQLOptions = {}, +) => { + debug(`Setting up mock for ${pattern} `, { + op, + responseKey, + deepMockKey, + }) + + await page.route(pattern, async (route) => { + // Setup + const routeUrl = route.request().url() + const routeOp = routeUrl.split('op=')[1] + const casedRouteOp = camelCaseResponseKey ? camelCase(routeOp) : routeOp + debug(`Got route `, { routeUrl, routeOp, casedRouteOp, op }) + + // Get original + const response = patchResponse + ? await ( + await route.fetch({ + headers: { ...route.request().headers(), MOCKED_PATCH: 'yes' }, + }) + ).json() + : {} + const originalResponse = { ...response?.data } + + // Set mock + const mockKey = responseKey ?? casedRouteOp + if (!mockKey) + throw Error( + `Invalid key for mock (mockKey=${mockKey}, responseKey=${responseKey}, op=${op})!\nYou probably need to change the 'op' or add 'responseKey'`, + ) + const mockResponse: Dict = deepMockKey + ? deepMock(originalResponse, deepMockKey, mockData) + : Object.fromEntries([[mockKey, mockData]]) + mockResponse.deepMocked = !!deepMockKey + mockResponse.mocked = true + + // Debug logging + debug(`Got a mock-match for > ${route.request().url()} < `, { + mockKey, + patchResponse, + }) + debug('(original):', originalResponse) + + const patchedData = mergeWith( + { ...originalResponse }, + mockResponse, + mergeOverwrite, + ) + const data: Dict = { data: {} } + data.data = patchedData + + // Debug logging + debug('(mocked): ', mockResponse) + debug('(merged): ', patchedData) + + // Mock injection + const body = JSON.stringify(data) + debug('Body:', body) + route.fulfill({ + body, + headers: { MOCKED: 'yes', DEEP_MOCKED: deepMockKey ? 'yes' : 'no' }, + }) + }) +} + +export const disableObjectKey = async ( + page: Page, + key: Matchable, + mockData?: T, +) => { + return await mockQGL(page, '**', mockData ?? `MOCKED-${key}`, { + deepMockKey: key, + patchResponse: true, + }) +} + +export const disablePreviousApplications = async (page: Page) => { + await mockQGL(page, 'ApplicationApplications', []) +} + +/** + * Mocks the i18n translations by returning mock data. + * + * @param {Page} page - The Playwright page object. + * @returns {Promise} + */ +export const disableI18n = async (page: Page) => { + return mockQGL(page, 'GetTranslations', { + 'mock.translation': 'YES-mocked', + }) +} + +/** + * Disables delegations by mocking the response to return an empty array. + * + * @param {Page} page - The Playwright page object. + * @returns {Promise} + */ +export const disableDelegations = async (page: Page) => { + return mockQGL(page, 'ActorDelegations', []) +} diff --git a/libs/testing/e2e/src/lib/support/email-account.ts b/libs/testing/e2e/src/lib/support/email-account.ts new file mode 100644 index 000000000000..03da8854b613 --- /dev/null +++ b/libs/testing/e2e/src/lib/support/email-account.ts @@ -0,0 +1,171 @@ +// use Nodemailer to get an Ethereal email inbox +// https://nodemailer.com/about/ +import { createTestAccount } from 'nodemailer' +// used to check the email inbox +import { connect } from 'imap-simple' +import { simpleParser } from 'mailparser' +import axios from 'axios' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { + GetIdentityVerificationAttributesCommand, + SESClient, + VerifyEmailAddressCommand, +} from '@aws-sdk/client-ses' +import { join } from 'path' +import { sessionsPath } from './session' +import { debug } from './utils' + +/** + * Register the email address with AWS SES, so we can send emails to it + * @param emailAccount + */ +const registerEmailAddressWithSES = async (emailAccount: { + getLastEmail(retries: number): Promise<{ + subject: string | undefined + text: string | undefined + html: string | false + } | null> + email: string +}) => { + const client = new SESClient({ region: 'eu-west-1' }) + await client.send( + new VerifyEmailAddressCommand({ EmailAddress: emailAccount.email }), + ) + const verifyMsg = await emailAccount.getLastEmail(4) + if (verifyMsg?.text) { + debug(`Verify message is ${verifyMsg.subject}: ${verifyMsg.text}`) + const verifyUrl = verifyMsg.text.match(/https:\/\/email-verification.+/) + if (!verifyUrl || verifyUrl.length !== 1) { + throw new Error( + `Email validation should have provided 1 URL but that did not happen. Here are the matches in the email message: ${JSON.stringify( + verifyUrl, + )}`, + ) + } + + await axios.get(verifyUrl[0]) + const emailVerifiedStatus = await client.send( + new GetIdentityVerificationAttributesCommand({ + Identities: [emailAccount.email], + }), + ) + if ( + emailVerifiedStatus.VerificationAttributes && + emailVerifiedStatus.VerificationAttributes[emailAccount.email][ + 'VerificationStatus' + ] !== 'Success' + ) { + throw new Error('Email identity still not validated in AWS SES') + } + } else { + throw new Error('Verification message not found.') + } +} + +export type EmailAccount = { + getLastEmail(retries: number): Promise<{ + subject: string | undefined + text: string | undefined + html: string | false + } | null> + email: string +} +const makeEmailAccount = async (name: string): Promise => { + const storagePath = join(sessionsPath, `${name}-email.json`) + const emailAccountExists = existsSync(storagePath) + const testAccount = emailAccountExists + ? (JSON.parse(readFileSync(storagePath, { encoding: 'utf-8' })) as { + user: string + pass: string + }) + : await createTestAccount() + + const emailConfig = { + imap: { + user: testAccount.user, + password: testAccount.pass, + host: 'imap.ethereal.email', + port: 993, + tls: true, + authTimeout: 10000, + }, + } + debug('created new email account %s', testAccount.user) + debug('for debugging, the password is %s', testAccount.pass) + const userEmail: EmailAccount = { + email: testAccount.user, + + /** + * Utility method for getting the last email + * for the Ethereal email account created above. + */ + async getLastEmail(retries: number): Promise { + // makes debugging very simple + debug('getting the last email') + debug(JSON.stringify(emailConfig)) + + debug('connecting to mail server...') + const connection = await connect(emailConfig) + debug('connected') + try { + // grab up to 50 emails from the inbox + debug('Opening inbox...') + await connection.openBox('INBOX') + debug('Opened inbox.') + const searchCriteria = ['UNSEEN'] + const fetchOptions = { + bodies: [''], + markSeen: true, + } + debug('Starting search for new messages...') + const messages = await connection.search(searchCriteria, fetchOptions) + debug('Search finished') + + if (!messages.length) { + debug('cannot find any emails') + if (retries <= 0) { + return null + } else { + await new Promise((r) => setTimeout(r, 5000)) + return userEmail.getLastEmail(retries - 1) + } + } else { + debug('there are %d messages', messages.length) + // grab the last email + const mail = await simpleParser( + messages[messages.length - 1].parts[0].body, + ) + debug(mail.subject ?? 'No subject') + debug(mail.text ?? 'No text') + + // and returns the main fields + return { + subject: mail.subject, + text: mail.text, + html: mail.html, + } + } + } finally { + // and close the connection to avoid it hanging + connection.end() + } + }, + } + + if (!emailAccountExists) { + await registerEmailAddressWithSES(userEmail) + } + writeFileSync( + storagePath, + JSON.stringify({ user: testAccount.user, pass: testAccount.pass }), + { encoding: 'utf-8' }, + ) + + return userEmail +} + +export { makeEmailAccount, registerEmailAddressWithSES } diff --git a/libs/testing/e2e/src/lib/support/i18n.ts b/libs/testing/e2e/src/lib/support/i18n.ts new file mode 100644 index 000000000000..d4378e64dc66 --- /dev/null +++ b/libs/testing/e2e/src/lib/support/i18n.ts @@ -0,0 +1,15 @@ +// // Create the `intl` object +import { createIntl, createIntlCache, MessageDescriptor } from '@formatjs/intl' +const cache = createIntlCache() +const intl = createIntl( + { + locale: 'is', + onError: (err) => { + if (err?.code == 'MISSING_TRANSLATION') return + console.warn(err) + }, + }, + cache, +) + +export const label = (l: MessageDescriptor) => intl.formatMessage(l) diff --git a/libs/testing/e2e/src/lib/support/login.ts b/libs/testing/e2e/src/lib/support/login.ts new file mode 100644 index 000000000000..cf9a735ffc47 --- /dev/null +++ b/libs/testing/e2e/src/lib/support/login.ts @@ -0,0 +1,109 @@ +import { expect, Page } from '@playwright/test' +import { urls, shouldSkipNavigation } from './urls' +import { debug } from './utils' + +export type CognitoCreds = { + username: string + password: string +} + +const getCognitoCredentials = (): CognitoCreds => { + const username = process.env.AWS_COGNITO_USERNAME + const password = process.env.AWS_COGNITO_PASSWORD + if (!username || !password) throw new Error('Cognito credentials missing') + return { + username, + password, + } +} + +export const cognitoLogin = async ( + page: Page, + home: string, + authUrl: string, + creds?: CognitoCreds, +) => { + if ( + page.url().startsWith('https://ids-users.auth.eu-west-1.amazoncognito.com/') + ) { + await page.getByRole('button', { name: 'ids-deprecated' }).click() + } + const { username, password } = creds ?? getCognitoCredentials() + const cognito = page.locator('form[name="cognitoSignInForm"]:visible') + await cognito.locator('input[id="signInFormUsername"]:visible').type(username) + const passwordInput = cognito.locator( + 'input[id="signInFormPassword"]:visible', + ) + + await passwordInput.selectText() + await passwordInput.type(password) + await cognito.locator('input[name="signInSubmitButton"]:visible').click() + + if (shouldSkipNavigation(home)) { + return + } + await page.waitForURL(new RegExp(`${home}|${authUrl}`)) +} + +export const idsLogin = async ( + page: Page, + phoneNumber: string, + home: string, + delegation?: string, +) => { + await page.waitForURL(`${urls.authUrl}/**`, { timeout: 15000 }) + const input = page.locator('#phoneUserIdentifier') + await input.type(phoneNumber, { delay: 100 }) + + const btn = page.locator('button[id="submitPhoneNumber"]') + await expect(btn).toBeEnabled() + await btn.click() + await page.waitForURL( + new RegExp(`${home}|${urls.authUrl}/(app/)?delegation`), + { + waitUntil: 'domcontentloaded', + }, + ) + + // Handle delegation on login + if (page.url().startsWith(urls.authUrl)) { + debug('Still on auth site') + /** + * Not using accessible selector here because this test needs to work on both the new and current login page at the same time to handle the transition gracefully + * TODO: use accessible selector when the new login pages is out + */ + const delegations = page.locator('.identity-card--name') + await expect(delegations).not.toHaveCount(0) + // Default to the first delegation + if (!delegation) await delegations.first().click() + else { + // Support national IDS and names + const filteredDelegations = page.getByRole('button', { + name: delegation.match(/^[0-9-]+$/) + ? delegation.replace(/(\d{6})-?(\d{4})/, '$1-$2') + : delegation, + }) + + await filteredDelegations.first().click() + } + await page.waitForURL(new RegExp(`${home}`), { + waitUntil: 'domcontentloaded', + }) + } +} + +export const switchUser = async ( + page: Page, + homeUrl: string, + name?: string, +) => { + await page.locator('data-testid=user-menu >> visible=true').click() + await page.getByRole('button', { name: 'Skipta um notanda' }).click() + + if (name) { + await page.getByRole('button', { name: name }).click() + await page.waitForURL(new RegExp(homeUrl), { + waitUntil: 'domcontentloaded', + }) + } +} diff --git a/libs/testing/e2e/src/lib/support/session.ts b/libs/testing/e2e/src/lib/support/session.ts new file mode 100644 index 000000000000..1d94caea6156 --- /dev/null +++ b/libs/testing/e2e/src/lib/support/session.ts @@ -0,0 +1,176 @@ +import { Browser, BrowserContext, expect, Page } from '@playwright/test' +import { existsSync, mkdirSync } from 'fs' +import { join } from 'path' +import { cognitoLogin, idsLogin } from './login' +import { JUDICIAL_SYSTEM_HOME_URL, urls } from './urls' +import { debug } from './utils' + +export const sessionsPath = join(__dirname, 'tmp-sessions') +if (!existsSync(sessionsPath)) { + mkdirSync(sessionsPath, { recursive: true }) +} + +/** + * Checks if Cognito authentication is needed (Dev and Staging) and performs the authentication when necessary + * @param page + * @param homeUrl + * @param authUrlPrefix + */ +const ensureCognitoSessionIfNeeded = async ( + page: Page, + homeUrl = '/', + authUrlPrefix = '', +) => { + const cognitoSessionValidation = await page.request.get(homeUrl) + if ( + cognitoSessionValidation + .url() + .startsWith('https://cognito.shared.devland.is/') || + cognitoSessionValidation + .url() + .startsWith('https://ids-users.auth.eu-west-1.amazoncognito.com/') + ) { + await page.goto(homeUrl) + await cognitoLogin(page, homeUrl, authUrlPrefix) + } else { + debug(`Cognito session exists`) + } +} + +/** + * Validates the IDS session and creates a new one if needed. Supports both the standard authentication session as well as NextAuth way. + * @param idsLoginOn + * @param page + * @param context + * @param homeUrl + * @param phoneNumber + * @param authUrlPrefix + * @param delegation - National ID of delegation to choose, if any + */ +const ensureIDSsession = async ( + idsLoginOn: { nextAuth?: { nextAuthRoot: string } } | boolean, + page: Page, + context: BrowserContext, + homeUrl: string, + phoneNumber: string, + authUrlPrefix: string, + delegation?: string, + authTrigger: ((page: Page) => Promise) | string = homeUrl, +) => { + if (typeof idsLoginOn === 'object' && idsLoginOn.nextAuth) { + const idsSessionValidation = await page.request.get( + `${idsLoginOn.nextAuth.nextAuthRoot}/api/auth/session`, + ) + const sessionObject = await idsSessionValidation.json() + if (!sessionObject.expires) { + const idsPage = await context.newPage() + if (typeof authTrigger === 'string') await idsPage.goto(authTrigger) + else authTrigger = await authTrigger(idsPage) + await idsLogin(idsPage, phoneNumber, authTrigger, delegation) + await idsPage.close() + } else { + debug(`IDS(next-auth) session exists`) + } + } else { + const idsSessionValidation = await page.request.get( + `${authUrlPrefix}/connect/sessioninfo`, + ) + const sessionHTML = await idsSessionValidation.text() + const sessionMatch = sessionHTML.match(/({.*?})/) + if (!sessionMatch || sessionMatch.length !== 2) { + throw new Error( + `IDS session html code has changed. Please review regex for extracting the session info.`, + ) + } + const sessionObject = JSON.parse(sessionMatch[1]) + if (sessionObject.status === 'No Session' || sessionObject.isExpired) { + const idsPage = await context.newPage() + if (typeof authTrigger === 'string') await idsPage.goto(authTrigger) + else authTrigger = await authTrigger(idsPage) + await idsLogin(idsPage, phoneNumber, authTrigger, delegation) + await idsPage.close() + } else { + debug(`IDS session exists`) + } + } +} + +export const session = async ({ + browser, + homeUrl = '/', + phoneNumber = '', + authUrl = urls.authUrl, + idsLoginOn = true, + delegation = '', + storageState = `playwright-sessions-${homeUrl}-${phoneNumber}`, + authTrigger = homeUrl, +}: { + browser: Browser + homeUrl?: string + phoneNumber?: string + authUrl?: string + idsLoginOn?: + | boolean + | { + nextAuth?: { + nextAuthRoot: string + } + } + delegation?: string + storageState?: string + authTrigger?: string | ((page: Page) => Promise) +}) => { + // Browser context storage + // default: sessions/phone x delegation/url + const storageStatePath = join( + sessionsPath, + storageState ?? + `sessions/${phoneNumber}x${delegation ?? phoneNumber}/${homeUrl}`, + ) + const context = existsSync(storageStatePath) + ? await browser.newContext({ storageState: storageStatePath }) + : await browser.newContext() + const page = await context.newPage() + const authUrlPrefix = authUrl ?? urls.authUrl + await ensureCognitoSessionIfNeeded(page, homeUrl, authUrlPrefix) + if (idsLoginOn) { + await ensureIDSsession( + idsLoginOn, + page, + context, + homeUrl, + phoneNumber, + authUrlPrefix, + delegation, + authTrigger, + ) + } + await page.close() + const sessionValidationPage = await context.newPage() + const sessionValidation = await sessionValidationPage.goto(homeUrl, { + waitUntil: 'domcontentloaded', + }) + await expect(sessionValidation?.url()).toMatch(homeUrl) + await sessionValidationPage.context().storageState({ path: storageStatePath }) + await sessionValidationPage.close() + return context +} + +export const judicialSystemSession = async ({ + browser, + homeUrl, +}: { + browser: Browser + homeUrl?: string +}) => { + const context = await browser.newContext() + const page = await context.newPage() + const authUrlPrefix = urls.authUrl + await ensureCognitoSessionIfNeeded( + page, + homeUrl ?? JUDICIAL_SYSTEM_HOME_URL, + authUrlPrefix, + ) + await page.close() + return context +} diff --git a/libs/testing/e2e/src/lib/support/urls.ts b/libs/testing/e2e/src/lib/support/urls.ts new file mode 100644 index 000000000000..6f2461b73d14 --- /dev/null +++ b/libs/testing/e2e/src/lib/support/urls.ts @@ -0,0 +1,110 @@ +export type TestEnvironment = 'local' | 'dev' | 'staging' | 'prod' + +export enum BaseAuthority { + dev = 'beta.dev01.devland.is', + staging = 'beta.staging01.devland.is', + ads = 'loftbru.dev01.devland.is', + judicialSystem = 'judicial-system.dev01.devland.is', + prod = 'island.is', + local = 'localhost', +} + +export enum AuthUrl { + dev = 'https://identity-server.dev01.devland.is', + staging = 'https://identity-server.staging01.devland.is', + prod = 'https://innskra.island.is', + local = dev, +} + +export const JUDICIAL_SYSTEM_HOME_URL = '/api/auth/login?nationalId=9999999999' +export const JUDICIAL_SYSTEM_JUDGE_HOME_URL = + '/api/auth/login?nationalId=0000000000' +export const JUDICIAL_SYSTEM_COA_JUDGE_HOME_URL = + '/api/auth/login?nationalId=0000000001' +export const JUDICIAL_SYSTEM_DEFENDER_HOME_URL = + '/api/auth/login?nationalId=0909090909' + +export const shouldSkipNavigation = (url: string) => { + return [ + JUDICIAL_SYSTEM_COA_JUDGE_HOME_URL, + JUDICIAL_SYSTEM_DEFENDER_HOME_URL, + JUDICIAL_SYSTEM_HOME_URL, + JUDICIAL_SYSTEM_JUDGE_HOME_URL, + ].includes(url) +} + +export const getEnvironmentBaseUrl = (authority: string) => { + const baseurlPrefix = process.env.BASE_URL_PREFIX ?? '' + const prefix = + (baseurlPrefix?.length ?? 0) > 0 && baseurlPrefix !== 'main' + ? `${baseurlPrefix}-` + : '' + return `https://${prefix}${authority}` +} +const localUrl = `http://${BaseAuthority.local}:${process.env.PORT ?? 4200}` +// This set of query params is used to hide the onboarding modal as well as force locale to Icelandic. +// Useful if you need to test something that is using icelandic labels for example. +const icelandicAndNoPopup = { + locale: 'is', + hide_onboarding_modal: 'true', +} +const addQueryParameters = ( + url: string, + parameters: Record, +): string => { + const urlObject = new URL(url, 'http://dummyurl.com') + + // Check if each parameter already exists in the URL's query string + for (const [key, value] of Object.entries(parameters)) { + if (!urlObject.searchParams.has(key)) { + urlObject.searchParams.set(key, value) + } + } + + return urlObject.toString().replace(/^http:\/\/dummyurl\.com/, '') +} + +export const icelandicAndNoPopupUrl = (url: string) => + addQueryParameters(url, icelandicAndNoPopup) + +const envs: { + [envName in TestEnvironment]: { + authUrl: string + islandisBaseUrl: string + adsBaseUrl: string + judicialSystemBaseUrl: string + } +} = { + dev: { + authUrl: AuthUrl.dev, + islandisBaseUrl: getEnvironmentBaseUrl(BaseAuthority.dev), + adsBaseUrl: getEnvironmentBaseUrl(BaseAuthority.ads), + judicialSystemBaseUrl: getEnvironmentBaseUrl(BaseAuthority.judicialSystem), + }, + staging: { + authUrl: AuthUrl.staging, + islandisBaseUrl: getEnvironmentBaseUrl(BaseAuthority.staging), + adsBaseUrl: getEnvironmentBaseUrl('loftbru.staging01.devland.is'), + judicialSystemBaseUrl: getEnvironmentBaseUrl( + 'judicial-system.staging01.devland.is', + ), + }, + prod: { + authUrl: AuthUrl.prod, + islandisBaseUrl: getEnvironmentBaseUrl(BaseAuthority.prod), + adsBaseUrl: getEnvironmentBaseUrl('loftbru.island.is'), + judicialSystemBaseUrl: getEnvironmentBaseUrl(BaseAuthority.prod), + }, + local: { + authUrl: AuthUrl.local, + islandisBaseUrl: localUrl, + adsBaseUrl: localUrl, + judicialSystemBaseUrl: localUrl, + }, +} + +export const env = (process.env.TEST_ENVIRONMENT ?? 'local') as TestEnvironment +const hotEnv = process.env.TEST_URL + ? { islandisBaseUrl: process.env.TEST_URL } + : {} +export const urls = { ...envs[env], ...hotEnv } diff --git a/libs/testing/e2e/src/lib/support/utils.ts b/libs/testing/e2e/src/lib/support/utils.ts new file mode 100644 index 000000000000..79d6f32ef814 --- /dev/null +++ b/libs/testing/e2e/src/lib/support/utils.ts @@ -0,0 +1,30 @@ +//import PDFDocument from 'pdfkit' +import { debuglog } from 'util' +import fs from 'fs' + +export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export const createMockPdf = () => { + fs.writeFile('./mockPdf.pdf', 'test', (e) => { + throw e + }) + /* + const doc = new PDFDocument() + doc.text('test') + doc.save() + doc.pipe(fs.createWriteStream('./mockPdf.pdf')) + doc.end() + */ +} + +export const deleteMockPdf = () => { + fs.unlink('./mockPdf.pdf', (err) => { + if (err) throw err + debug('Successfully deleted mockPdf file.') + }) +} + +// Set NODE_DEBUG=system-e2e in your environment when testing to show debug messages +export const debug = (msg: string, ...args: unknown[]) => { + debuglog('system-e2e')(msg, ...args) +} diff --git a/libs/testing/e2e/src/lib/utils/locator-helpers.ts b/libs/testing/e2e/src/lib/utils/locator-helpers.ts new file mode 100644 index 000000000000..27299fef1bd6 --- /dev/null +++ b/libs/testing/e2e/src/lib/utils/locator-helpers.ts @@ -0,0 +1,24 @@ +import { Locator, Page } from '@playwright/test' + +type Roles = 'heading' | 'button' | 'radio' +export const locatorByRole = ( + role: Roles | string, + name: string | { name: string }, +): string => + typeof name === 'string' + ? `role=${role}[name="${name}"]` + : `role=${role}[name="${name.name}"]` + +export const helpers = (page: Page) => { + return { + findByRole: ( + role: Roles | string, + name: string | { name: string }, + ): Locator => { + return page.locator(locatorByRole(role, name)) + }, + findByTestId: (name: string): Locator => + page.locator(`[data-testid="${name}"]`), + proceed: async () => await page.locator('[data-testid="proceed"]').click(), + } +} diff --git a/libs/testing/e2e/src/lib/utils/pageHelpers.ts b/libs/testing/e2e/src/lib/utils/pageHelpers.ts new file mode 100644 index 000000000000..e1c8a4233eb8 --- /dev/null +++ b/libs/testing/e2e/src/lib/utils/pageHelpers.ts @@ -0,0 +1,2 @@ +export const getInputByName = (name: string) => `input[name="${name}"]` +export const getTextareaByName = (name: string) => `textarea[name="${name}"]` diff --git a/package.json b/package.json index 9495d132d2ee..0a9d57c78aec 100644 --- a/package.json +++ b/package.json @@ -352,6 +352,7 @@ "@nx/nest": "19.4.0", "@nx/next": "19.4.0", "@nx/node": "19.4.0", + "@nx/playwright": "19.4.0", "@nx/react": "19.4.0", "@nx/storybook": "19.4.0", "@nx/web": "19.4.0", diff --git a/yarn.lock b/yarn.lock index 792bb3ab8d25..89e37c725552 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15549,6 +15549,25 @@ __metadata: languageName: node linkType: hard +"@nx/playwright@npm:19.4.0": + version: 19.4.0 + resolution: "@nx/playwright@npm:19.4.0" + dependencies: + "@nx/devkit": 19.4.0 + "@nx/eslint": 19.4.0 + "@nx/js": 19.4.0 + "@phenomnomnominal/tsquery": ~5.0.1 + minimatch: 9.0.3 + tslib: ^2.3.0 + peerDependencies: + "@playwright/test": ^1.36.0 + peerDependenciesMeta: + "@playwright/test": + optional: true + checksum: a3696125e1b18447f1591111679b3247926d9c716a9e6c66de4e9187c47a8a7a6231599abb4f9a36c1fcd7fe923eade48c9b799dc20478934b1fa2e032bc1c45 + languageName: node + linkType: hard + "@nx/react@npm:19.4.0": version: 19.4.0 resolution: "@nx/react@npm:19.4.0" @@ -38632,6 +38651,7 @@ __metadata: "@nx/nest": 19.4.0 "@nx/next": 19.4.0 "@nx/node": 19.4.0 + "@nx/playwright": 19.4.0 "@nx/react": 19.4.0 "@nx/storybook": 19.4.0 "@nx/web": 19.4.0