diff --git a/package.json b/package.json index 5f78a9d0b..fd1284c20 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,13 @@ "prettier:check": "prettier src --check", "prettier:fix": "prettier src --write", "prepare": "husky install", - "test:watch": "TZ=UTC vitest", - "test": "TZ=UTC vitest run", - "test:e2e": "TZ=UTC playwright test --project=e2e", - "test:user": "TZ=UTC playwright test --project=user", - "test:smoke": "TZ=UTC playwright test --project=smoke", - "test:ui": "TZ=UTC vitest --ui", - "coverage": "TZ=UTC vitest run --coverage" + "test:watch": "cross-env TZ=UTC vitest", + "test": "cross-env TZ=UTC vitest run", + "test:e2e": "cross-env TZ=UTC playwright test --project=e2e", + "test:user": "cross-env TZ=UTC playwright test --project=user", + "test:smoke": "cross-env TZ=UTC playwright test --project=smoke", + "test:ui": "cross-env TZ=UTC vitest --ui", + "coverage": "cross-env TZ=UTC vitest run --coverage" }, "dependencies": { "@datadog/browser-logs": "^6.5.0", @@ -106,7 +106,8 @@ "vite-plugin-checker": "^0.13.0", "vite-plugin-node-stdlib-browser": "^0.2.1", "vitest": "^4.0.8", - "vitest-canvas-mock": "^0.3.3" + "vitest-canvas-mock": "^0.3.3", + "cross-env": "^7.0.3" }, "resolutions": { "qs": ">=6.14.1", diff --git a/tests/config.ts b/tests/config.ts index a9656f53d..73365dead 100644 --- a/tests/config.ts +++ b/tests/config.ts @@ -15,7 +15,7 @@ dotenv.config({ export const runtimeConfig = { storageRoot: 'storage', - storageState: process.env.PLAYWRIGHT_STORAGE_STATE || 'storage/default.json', + storageState: process.env.PLAYWRIGHT_STORAGE_STATE || 'tests/.auth/admin.json', baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000', apiBaseURL: process.env.PLAYWRIGHT_API_BASE_URL || 'http://localhost:3000', diff --git a/tests/fixtures/api.fixtures.ts b/tests/fixtures/api.fixtures.ts index aa8bb8b8a..7c5209b0b 100644 --- a/tests/fixtures/api.fixtures.ts +++ b/tests/fixtures/api.fixtures.ts @@ -1,6 +1,7 @@ import {request, test as base} from '@playwright/test'; import {UserAPI} from "../utils/api-client/user-api"; import {AppletAPI} from "../utils/api-client/applet-api"; +import {InvitationsAPI} from "../utils/api-client/invitations-api"; import {runtimeConfig} from "../config"; import {readStorageFile} from "../utils/file"; @@ -9,6 +10,8 @@ type Fixtures = { adminUserApi: UserAPI appletApi: AppletAPI adminAppletApi: AppletAPI + invitationsApi: InvitationsAPI + adminInvitationsApi: InvitationsAPI }; @@ -58,6 +61,22 @@ export const test = base.extend({ const api = new AppletAPI(context); await use(api); await api.dispose(); + }, + + invitationsApi: async ({}: any, use) => { + const context = await initContext(runtimeConfig.userTokenFile) + + const api = new InvitationsAPI(context); + await use(api); + await api.dispose(); + }, + + adminInvitationsApi: async ({}: any, use) => { + const context = await initContext(runtimeConfig.adminTokenFile) + + const api = new InvitationsAPI(context); + await use(api); + await api.dispose(); } }); diff --git a/tests/suites/e2e/specs/activities/complete-assesment.spec.ts b/tests/suites/e2e/specs/activities/complete-assesment.spec.ts index b3544f0c2..d00059e00 100644 --- a/tests/suites/e2e/specs/activities/complete-assesment.spec.ts +++ b/tests/suites/e2e/specs/activities/complete-assesment.spec.ts @@ -1,14 +1,37 @@ import { test, expect } from '../../../../fixtures/pages.fixture' +import { requirePlaywrightAuthCredentials } from '../../../../utils/credentials'; test.describe('Activity Completion', () => { test('User can complete an assessment and submit answers', async ({ appletListPage, appletDetailsPage, - page + loginPage, + page, + baseURL, }) => { - // Navigate to applets list + const { email, password } = requirePlaywrightAuthCredentials(); + + await loginPage.goto(baseURL); + await loginPage.login(email, password); + await page.waitForURL(/.*\/protected\/applets/, { timeout: 15000 }); + await page.goto('/protected/applets'); await expect(page).toHaveURL(/.*\/protected\/applets/); + + const appletListExists = await page + .waitForSelector('[data-testid="applet-list"]', { timeout: 10000 }) + .catch(() => null); + + if (!appletListExists) { + const noAppletsVisible = await page + .waitForSelector('text=/no applets/i', { timeout: 10000 }) + .catch(() => null); + + if (noAppletsVisible) { + test.skip('No applets available in this environment to complete an assessment'); + } + } + // Give the applet list more time to load and become visible await expect(appletListPage.appletList).toBeVisible({ timeout: 10000 }); diff --git a/tests/utils/api-client/applet-api.ts b/tests/utils/api-client/applet-api.ts index e95c08f7c..39586401b 100644 --- a/tests/utils/api-client/applet-api.ts +++ b/tests/utils/api-client/applet-api.ts @@ -1,4 +1,6 @@ -// TODO Perhaps a better implementation +/** + * API helper for applet-related operations in the curious API client. + */ import { expect } from '@playwright/test'; import {CuriousApi} from "./api"; @@ -22,6 +24,12 @@ interface createAppletPayload { export class AppletAPI extends CuriousApi { + /** + * Create a new applet via the API. + * + * @param appletData - The applet payload. + * @returns The API response. + */ async createApplet(appletData: createAppletPayload): Promise { const response = await this.apiContext.post('/applets', { data: appletData }); console.log(response); @@ -29,6 +37,13 @@ export class AppletAPI extends CuriousApi { return await response.json(); } + /** + * Create a manager invitation for an applet. + * + * @param inviteData - The invitation payload. + * @param appletID - The applet identifier. + * @returns The API response. + */ async createManagerInvite(inviteData: { email: string; firstName: string; lastName: string; language: string; role: string; workspacePrefix: string; title: string; }, appletID: string): Promise { const response = await this.apiContext.post(`/invitations/${appletID}/managers`, { data: inviteData }); console.log(response); @@ -36,6 +51,12 @@ export class AppletAPI extends CuriousApi { return await response.json(); } + /** + * Retrieve invitations for a specific applet. + * + * @param appletID - The applet identifier. + * @returns The API response. + */ async getAppletInvitations(appletID: string): Promise { const response = await this.apiContext.get(`/invitations?page=1&limit=10&ordering=-id&appletId=${appletID}`); console.log(response); diff --git a/tests/utils/api-client/invitations-api.ts b/tests/utils/api-client/invitations-api.ts new file mode 100644 index 000000000..6ec787a44 --- /dev/null +++ b/tests/utils/api-client/invitations-api.ts @@ -0,0 +1,50 @@ +import {CuriousApi} from "./api"; +import type { + GetInvitationSuccessResponse, +} from '../../../src/shared/api/types/invitation'; + +/** + * API helper for invitation lifecycle operations. + */ +export class InvitationsAPI extends CuriousApi { + /** + * Fetch the invitation details for a given invitation ID. + * + * @param invitationId - The invitation identifier. + * @returns The invitation response object. + */ + async getInvitation(invitationId: string): Promise { + const res = await this.apiContext.get(`/invitations/${invitationId}`); + if (!res.ok()) { + const text = await res.text(); + throw new Error(`getInvitation failed: ${res.status()} ${res.statusText()} - ${text}`); + } + return res.json() as Promise; + } + + /** + * Accept an invitation by ID. + * + * @param invitationId - The invitation identifier. + */ + async acceptInvitation(invitationId: string): Promise { + const res = await this.apiContext.post(`/invitations/${invitationId}/accept`); + if (!res.ok()) { + const text = await res.text(); + throw new Error(`acceptInvitation failed: ${res.status()} ${res.statusText()} - ${text}`); + } + } + + /** + * Decline an invitation by ID. + * + * @param invitationId - The invitation identifier. + */ + async declineInvitation(invitationId: string): Promise { + const res = await this.apiContext.post(`/invitations/${invitationId}/decline`); + if (!res.ok()) { + const text = await res.text(); + throw new Error(`declineInvitation failed: ${res.status()} ${res.statusText()} - ${text}`); + } + } +} \ No newline at end of file diff --git a/tests/utils/api-client/user-api.ts b/tests/utils/api-client/user-api.ts index b0e1eab3e..1f23a7bd2 100644 --- a/tests/utils/api-client/user-api.ts +++ b/tests/utils/api-client/user-api.ts @@ -1,5 +1,8 @@ import {CuriousApi} from "./api"; +/** + * Payload used to create a user through the curious API client. + */ interface CreateUserPayload { confirmPassword?: string; email: string; @@ -8,6 +11,9 @@ interface CreateUserPayload { password: string; } +/** + * Response shape returned from the create user endpoint. + */ interface CreateUserResponse { result: { email: string; @@ -18,7 +24,12 @@ interface CreateUserResponse { } export class UserAPI extends CuriousApi { - + /** + * Create a user through the API client. + * + * @param payload - The new user details. + * @returns The created user response. + */ async createUser(payload: CreateUserPayload): Promise { try { this.log(`Creating user: ${payload.email}`); @@ -39,6 +50,14 @@ export class UserAPI extends CuriousApi { } } + /** + * Authenticate a user with retry support. + * + * @param email - The user email. + * @param password - The user password. + * @param retries - The number of times to retry on failure. + * @param delayMs - Delay between retries in milliseconds. + */ async login(email: string, password: string, retries = 3, delayMs = 1000) { this.log(`Attempting login for ${email} with ${retries} retries`); for (let attempt = 1; attempt <= retries; attempt++) { @@ -68,6 +87,9 @@ export class UserAPI extends CuriousApi { } } + /** + * Dispose the underlying API request context. + */ async dispose() { try { await this.apiContext.dispose(); diff --git a/tests/utils/api.ts b/tests/utils/api.ts index 45004bfcc..f1df4fcef 100644 --- a/tests/utils/api.ts +++ b/tests/utils/api.ts @@ -1,12 +1,27 @@ import {APIRequestContext, request} from '@playwright/test'; import {runtimeConfig} from "../config"; -// Construct a URL for API endpoints +/** + * Construct an API URL from a base URL and an endpoint path. + * Removes duplicate slashes when necessary. + * + * @param baseUrl - The base API URL. + * @param endpoint - The endpoint path to append. + * @returns A normalized URL string. + */ export const constructApiUrl = (baseUrl: string, endpoint: string): string => { return `${baseUrl.replace(/\/+$/, '')}/${endpoint.replace(/^\/+/, '')}`; }; -// Generic POST request helper +/** + * Send a generic API request using a Playwright APIRequestContext. + * + * @param apiRequestContext - The Playwright request context. + * @param url - The full request URL. + * @param data - The JSON payload to send. + * @param method - The HTTP method to use. + * @returns The parsed JSON response body. + */ export const postToApi = async ( apiRequestContext: APIRequestContext, url: string, @@ -45,9 +60,11 @@ type ApiLoginResponse = { } /** - * Perform an API authentication and return an access token on success - * @param email - * @param password + * Perform an API authentication request and return the access token. + * + * @param email - User email to authenticate. + * @param password - User password to authenticate. + * @returns A bearer access token string. */ export const performLogin = async (email: string, password: string): Promise => { const api = await request.newContext({ baseURL: runtimeConfig.apiBaseURL }); diff --git a/tests/utils/credentials.ts b/tests/utils/credentials.ts new file mode 100644 index 000000000..fa2f08536 --- /dev/null +++ b/tests/utils/credentials.ts @@ -0,0 +1,87 @@ +/** + * Credentials used by Playwright end-to-end tests. + * + * `source` identifies whether the credentials were loaded for an admin + * or regular user login. + */ +export type PlaywrightCredentials = { + email: string; + password: string; + source: 'admin' | 'user'; +}; + +/** + * Read an environment variable from standard or nested `uat` format. + * + * @param key - The environment variable name to resolve. + * @returns The string value if present, otherwise an empty string. + */ +const getEnvValue = (key: string): string => { + const value = process.env[key] as string | undefined; + if (value) return value; + const uatValue = (process.env as any)?.uat?.[key]; + return typeof uatValue === 'string' ? uatValue : ''; +}; + +/** + * Load a pair of Playwright credentials if both email and password exist. + * + * @param emailKey - Environment key for the email address. + * @param passwordKey - Environment key for the password. + * @param source - Indicates whether these are admin or user credentials. + * @returns The credentials object, or `null` if either value is missing. + */ +const loadCredentials = ( + emailKey: string, + passwordKey: string, + source: 'admin' | 'user', +): PlaywrightCredentials | null => { + const email = getEnvValue(emailKey); + const password = getEnvValue(passwordKey); + return email && password ? { email, password, source } : null; +}; + +/** + * Return Playwright user credentials if available. + */ +export const getPlaywrightUserCredentials = (): PlaywrightCredentials | null => + loadCredentials('PLAYWRIGHT_USER_EMAIL', 'PLAYWRIGHT_USER_PASSWORD', 'user'); + +/** + * Return Playwright admin credentials if available. + */ +export const getPlaywrightAdminCredentials = (): PlaywrightCredentials | null => + loadCredentials('PLAYWRIGHT_ADMIN_USER_EMAIL', 'PLAYWRIGHT_ADMIN_USER_PASSWORD', 'admin'); + +/** + * Prefer admin credentials, then fall back to user credentials. + */ +export const getPlaywrightAuthCredentials = (): PlaywrightCredentials | null => { + return getPlaywrightAdminCredentials() ?? getPlaywrightUserCredentials(); +}; + +/** + * Require user credentials and throw a helpful error if they are missing. + */ +export const requirePlaywrightUserCredentials = (): PlaywrightCredentials => { + const creds = getPlaywrightUserCredentials(); + if (!creds) { + throw new Error( + 'Missing Playwright user credentials. Set PLAYWRIGHT_USER_EMAIL and PLAYWRIGHT_USER_PASSWORD or process.env.uat.PLAYWRIGHT_EMAIL / PLAYWRIGHT_PASSWORD.', + ); + } + return creds; +}; + +/** + * Require admin or user auth credentials and throw a helpful error if they are missing. + */ +export const requirePlaywrightAuthCredentials = (): PlaywrightCredentials => { + const creds = getPlaywrightAuthCredentials(); + if (!creds) { + throw new Error( + 'Missing Playwright auth credentials. Set PLAYWRIGHT_ADMIN_USER_EMAIL / PLAYWRIGHT_ADMIN_USER_PASSWORD or PLAYWRIGHT_USER_EMAIL / PLAYWRIGHT_USER_PASSWORD (or process.env.uat equivalents).', + ); + } + return creds; +}; diff --git a/tests/utils/data/users.ts b/tests/utils/data/users.ts index a85fd9a2a..04254e264 100644 --- a/tests/utils/data/users.ts +++ b/tests/utils/data/users.ts @@ -7,6 +7,12 @@ export interface UserData { password: string; } +/** + * Generate a random user object for test data. + * + * @param password - Optional password, defaulting to a strong sample password. + * @returns A user object suitable for account creation. + */ export function generateRandomUser(password?: string): UserData { const uid = crypto.randomUUID(); @@ -18,6 +24,12 @@ export function generateRandomUser(password?: string): UserData { }; } +/** + * Create a unique email address based on an optional base email. + * + * @param baseEmail - An optional base email to preserve the domain. + * @returns A unique email string. + */ export function generateUniqueEmail(baseEmail?: string): string { if (baseEmail && baseEmail.includes('@')) { const [local, domain] = baseEmail.split('@'); diff --git a/tests/utils/file.ts b/tests/utils/file.ts index 384d89fdf..4cd22941e 100644 --- a/tests/utils/file.ts +++ b/tests/utils/file.ts @@ -1,10 +1,23 @@ import path from "path"; import fs from "fs"; +/** + * Build a platform-safe storage filename from path segments. + * + * @param paths - Individual path segments. + * @returns The joined filename. + */ export const generateStorageFilename = (...paths: string[]): string => { return path.join(...paths); } +/** + * Write raw string data to a storage file, creating parent directories as needed. + * + * @param data - The contents to write. + * @param filename - The target filename. + * @returns The filename that was written. + */ export const writeStorageFile = (data: string, filename: string): string => { fs.mkdirSync(path.dirname(filename), { recursive: true }); fs.writeFileSync(filename, data); @@ -12,6 +25,12 @@ export const writeStorageFile = (data: string, filename: string): string => { return filename; } +/** + * Read the contents of a storage file as UTF-8. + * + * @param filename - The file to read. + * @returns The file contents. + */ export const readStorageFile = (filename: string): string => { return fs.readFileSync(filename, 'utf-8') } diff --git a/tests/utils/loginPage.ts b/tests/utils/loginPage.ts new file mode 100644 index 000000000..5c55d1738 --- /dev/null +++ b/tests/utils/loginPage.ts @@ -0,0 +1,73 @@ +import { Page } from '@playwright/test'; +import { UserAPI } from './userApi'; // Import the improved class + +/** + * Perform a UI login flow by navigating to the login page and submitting credentials. + * + * @param page - The Playwright page instance. + * @param url - The login page URL. + * @param email - The email address to sign in with. + * @param password - The corresponding password. + */ +export const UIlogin = async (page: Page, url: any, email: string, password: string) => { + await page.goto(url); + // Fill in login form + await page.fill('input[name="email"]', email || ''); + await page.fill('input[name="password"]', password || ''); + // Submit the form + await page.click('button[type="submit"]'); +}; +/** + * Perform an admin login via API and inject the auth token into the browser context. + * + * @param page - The Playwright page instance. + * @param email - The admin email address. + * @param password - The admin password. + * @returns The access token string. + */ +export const apiAdminLogin = async (page: Page, email: string, password: string) => { + const userApi = new UserAPI(); + await userApi.init(); + + try { + // Perform login using the API context from UserAPI + const loginResponse = await userApi.login(email, password); + const token = loginResponse.result.token.accessToken; + + if (!loginResponse.ok()) { + throw new Error(`Login failed: ${loginResponse.status()} - ${await loginResponse.text()}`); + } + + // Inject token into localStorage for browser context + await page.addInitScript((tokenValue) => { + localStorage.setItem('Bearer', tokenValue); + }, token); + + return token; // Return token for further use if needed + } catch (error) { + console.error('Error during admin login:', error); + throw error; + } finally { + await userApi.dispose(); + } +}; + +/** + * Walk through the UI flow to create a new user account. + * + * @param page - The Playwright page instance. + * @param email - The email address for the new account. + * @param password - The password for the new account. + */ +export const createAccountForm = async (page: Page, email: string, password: string) => { + await page.goto('/login'); + await page.getByText('Create an account').click(); + await page.waitForURL('/signup'); + await page.fill('input[name="email"]', `${email + Date.now()}@example.com`); + await page.fill('input[name="firstName"]', 'Automation'); + await page.fill('input[name="lastName"]', 'Tester'); + await page.fill('input[name="password"]', password); + await page.fill('input[name="confirmPassword"]', password); + await page.getByRole('checkbox', { name: 'I agree to the Terms of Service' }).check(); + await page.getByRole('button', { name: 'Create Account' }).click(); +}; diff --git a/tests/utils/ui.ts b/tests/utils/ui.ts index f6c973529..041056619 100644 --- a/tests/utils/ui.ts +++ b/tests/utils/ui.ts @@ -1,6 +1,14 @@ import {Page} from "@playwright/test"; import {AuthSelectors} from "./selectors/auth.selectors"; +/** + * Complete the standard UI login flow. + * + * @param page - The Playwright page instance. + * @param url - The login URL to navigate to. + * @param email - The user email to enter. + * @param password - The user password to enter. + */ export const performUiLogin = async (page: Page, url: string, email: string, password: string) => { console.log(`Logging in to ${url}`); await page.goto(url); diff --git a/yarn.lock b/yarn.lock index 666065e5a..9985d2f0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2885,7 +2885,14 @@ create-require@^1.1.1: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-env@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-spawn@^7.0.1, cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==