Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down
19 changes: 19 additions & 0 deletions tests/fixtures/api.fixtures.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -9,6 +10,8 @@ type Fixtures = {
adminUserApi: UserAPI
appletApi: AppletAPI
adminAppletApi: AppletAPI
invitationsApi: InvitationsAPI
adminInvitationsApi: InvitationsAPI
};


Expand Down Expand Up @@ -58,6 +61,22 @@ export const test = base.extend<Fixtures>({
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();
}

});
Expand Down
27 changes: 25 additions & 2 deletions tests/suites/e2e/specs/activities/complete-assesment.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });

Expand Down
23 changes: 22 additions & 1 deletion tests/utils/api-client/applet-api.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -22,20 +24,39 @@ 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<any> {
const response = await this.apiContext.post('/applets', { data: appletData });
console.log(response);
expect(response.ok()).toBeTruthy();
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<any> {
const response = await this.apiContext.post(`/invitations/${appletID}/managers`, { data: inviteData });
console.log(response);
expect(response.ok()).toBeTruthy();
return await response.json();
}

/**
* Retrieve invitations for a specific applet.
*
* @param appletID - The applet identifier.
* @returns The API response.
*/
async getAppletInvitations(appletID: string): Promise<any> {
const response = await this.apiContext.get(`/invitations?page=1&limit=10&ordering=-id&appletId=${appletID}`);
console.log(response);
Expand Down
50 changes: 50 additions & 0 deletions tests/utils/api-client/invitations-api.ts
Original file line number Diff line number Diff line change
@@ -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<GetInvitationSuccessResponse> {
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<GetInvitationSuccessResponse>;
}

/**
* Accept an invitation by ID.
*
* @param invitationId - The invitation identifier.
*/
async acceptInvitation(invitationId: string): Promise<void> {
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<void> {
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}`);
}
}
}
24 changes: 23 additions & 1 deletion tests/utils/api-client/user-api.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,6 +11,9 @@ interface CreateUserPayload {
password: string;
}

/**
* Response shape returned from the create user endpoint.
*/
interface CreateUserResponse {
result: {
email: string;
Expand All @@ -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<CreateUserResponse> {
try {
this.log(`Creating user: ${payload.email}`);
Expand All @@ -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++) {
Expand Down Expand Up @@ -68,6 +87,9 @@ export class UserAPI extends CuriousApi {
}
}

/**
* Dispose the underlying API request context.
*/
async dispose() {
try {
await this.apiContext.dispose();
Expand Down
27 changes: 22 additions & 5 deletions tests/utils/api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string> => {
const api = await request.newContext({ baseURL: runtimeConfig.apiBaseURL });
Expand Down
Loading