Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,5 @@ backups/
/playwright/.cache/

.claude

load_testing/data/
24 changes: 22 additions & 2 deletions load_testing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,33 @@
### Usage (Docker)

```shell
./scripts/k6.sh -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=####
./scripts/k6.sh run /app/learn.smoke.ts -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=####
```

### Usage (local k6)

- Install [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/)

```shell
k6 run learn.ts -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=####
k6 run learn.smoke.ts -e BACKEND_BASE_URL=#### -e FRONTEND_BASE_URL=####
```

### Available tests

**Note: the numbers for average-load/stress should be updated over time**

- `learn.smoke.ts` - for lightweight smoke testing of deployments (4 users)
- `learn.average-load.ts` - simulates an average amount of user load (100 users)
- `learn.stress.ts` - simulates a stressful amount of user load (200 users)

### Environment Variables

**Note: for base urls, if you access the services via a port, include the port number**

| Name | Decription | Example value |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| `BACKEND_BASE_URL` | The base url to the backend API service. | `https://api.learn.odl.local` |
| `FRONTEND_BASE_URL` | The base url to the frontend service. | `https://learn.odl.local` |
| `SSO_BASE_URL` | The base url to the keycloak service. | `https://keycloak.odl.local` |
| `IGNORE_HTTPS_ERRORS` | Ignore https certificate errors. Only recommemded for local test certificates. | `true` |
| `USERS_JSON_FILE` | Data file for users auth info. Expected to be in the format `[{"email": "<EMAIL>", "password": "<PASSWORD>"}, ...]`. Put this is the `data/` subdirectory as files in there are gitignored. | `data/users.json` |
44 changes: 38 additions & 6 deletions load_testing/auth.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,45 @@
import { SharedArray } from "k6/data"

export function getAccessToken(): String {
export type AuthCredential = {
email: string
password: string
}

export function getAccessToken(): string | null {
return __ENV.AUTH_ACCESS_TOKEN
}

export const users = new SharedArray("users", function () {
if (!__ENV.USERS_JSON_FILE) {
return []
export function hasAccessToken(): boolean {
return !!getAccessToken()
}

function _validate_credentials(credentials) {
if (!Array.isArray(credentials)) {
throw Error("Expected an array of credentials")
}

for (let index = 0; index < credentials.length; index++) {
const credential = credentials[index]
if (!Object.hasOwn(credential, "email")) {
throw Error(`User entry is missing 'email' at index ${index}`)
}
if (!Object.hasOwn(credential, "password")) {
throw Error(`User entry is missing 'password' at index ${index}`)
}
}
}

export const credentials: AuthCredential[] = new SharedArray(
"credentials",
function () {
if (!__ENV.USERS_JSON_FILE) {
return []
}

const parsed = JSON.parse(open(__ENV.USERS_JSON_FILE))

_validate_credentials(parsed)

return JSON.parse(open(__ENV.USERS_JSON_FILE))
})
return parsed
},
)
31 changes: 20 additions & 11 deletions load_testing/backend/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,39 @@ import {
import { createV0Client, createV1Client } from "./client/client.ts"
import { NewsEventsListParams } from "./client/v0/api.schemas.ts"
import { FeaturedListParams } from "./client/v1/api.schemas.ts"
import { hasAccessToken } from "../auth.ts"

export function testBackend() {
group("api", function () {
group("v0", function () {
exec.vu.metrics.tags.apiVersion = "v0"

const client = createV0Client()
let res = client.videoShortsList({ limit: 50 })
group("video shorts", () => {
const res = client.videoShortsList({ limit: 50 })

check(res, {
"is status 200": (r) => r.response.status === 200,
"has results": (r) => r.response.json("results").length > 0,
check(res, {
"is status 200": (r) => r.response.status === 200,
"has results": (r) => r.response.json("results").length > 0,
})
})

res = client.usersMeRetrieve()
check(res, {
"is status 200": (r) => r.response.status === 200,
group("users/me", () => {
const res = client.usersMeRetrieve()
check(res, {
"is status 200": (r) => r.response.status === 200,
"expected auth state": (r) =>
r.response.json("is_authenticated") === hasAccessToken(),
})
})

res = client.testimonialsList({ position: 1 })
group("testimonials", () => {
const res = client.testimonialsList({ position: 1 })

check(res, {
"is status 200": (r) => r.response.status === 200,
"has results": (r) => r.response.json("results").length > 0,
check(res, {
"is status 200": (r) => r.response.status === 200,
"has results": (r) => r.response.json("results").length > 0,
})
})

group("news", function () {
Expand Down
20 changes: 15 additions & 5 deletions load_testing/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { NewBrowserContextOptions } from "k6/browser"

export const BACKEND_BASE_URL: string = __ENV.BACKEND_BASE_URL
export const FRONTEND_BASE_URL: string = __ENV.FRONTEND_BASE_URL
export const BACKEND_BASE_URL: string = __ENV.BACKEND_BASE_URL?.replace(
/\/$/,
"",
)
export const FRONTEND_BASE_URL: string = __ENV.FRONTEND_BASE_URL?.replace(
/\/$/,
"",
)
export const SSO_BASE_URL: string = (
__ENV.SSO_BASE_URL ?? "http://localhost"
).replace(/\/$/, "")

export const IGNORE_HTTPS_ERRORS: boolean =
(__ENV.IGNORE_HTTPS_ERRORS || "false").toLowerCase() == "true"

export const BROWSER_CONTEXT_OPTIONS: NewBrowserContextOptions = {
ignoreHTTPSErrors: Object.hasOwn(__ENV, "BROWSER_IGNORE_HTTPS_ERRORS")
? __ENV.BROWSER_IGNORE_HTTPS_ERRORS
: false,
ignoreHTTPSErrors: IGNORE_HTTPS_ERRORS,
}
Empty file added load_testing/data/.keep
Empty file.
164 changes: 146 additions & 18 deletions load_testing/frontend/test.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,180 @@
import { check } from "k6"
import { browser, Page } from "k6/browser"
import { randomIntBetween } from "https://jslib.k6.io/k6-utils/1.2.0/index.js"
import { BROWSER_CONTEXT_OPTIONS, FRONTEND_BASE_URL } from "../config.ts"
import {
randomIntBetween,
randomItem,
} from "https://jslib.k6.io/k6-utils/1.2.0/index.js"
import {
BROWSER_CONTEXT_OPTIONS,
FRONTEND_BASE_URL,
SSO_BASE_URL,
} from "../config.ts"
import { AuthCredential, credentials } from "../auth.ts"
import { escapeRegex } from "../utils.ts"

async function home(page: Page) {
type Context = {
loggedIn: boolean
credential: AuthCredential | null
}

async function home(page: Page, context: Context) {
await page.goto(FRONTEND_BASE_URL)

const carousel = await page.getByTestId("resource-carousel")
const articlesCount = await carousel.locator("article").count()
const articles = carousel.locator("article")

try {
await articles.first().waitFor({ timeout: 10_000 })
} catch {
// carousel never populated; let the assertion below report it
}

const articlesCount = await articles.count()

await check(carousel, {
"has 12 items": () => articlesCount === 12,
"home page carousel has 12 items": () => articlesCount === 12,
})

await carousel
.locator("article")
.nth(randomIntBetween(0, articlesCount - 1))
.click()
if (articlesCount === 0) {
return
}

await articles.nth(randomIntBetween(0, articlesCount - 1)).click()
}
Comment on lines 20 to 43

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition + multi-element matcher. The data-testid mounts immediately as an empty wrapper; article children only appear after React Query resolves, so count() reads 0 and .nth(0).click() then hangs ~30s on a missing element. Also, the home page renders several ResourceCarousel instances — getByTestId matches them all; scope to first.

Suggested change
async function home(page: Page, context: Context) {
await page.goto(FRONTEND_BASE_URL)
const carousel = page.getByTestId("resource-carousel").first()
const articles = carousel.locator("article")
try {
await articles.first().waitFor({ timeout: 10_000 })
} catch {
// carousel never populated; let the assertion below report it
}
const articlesCount = await articles.count()
await check(carousel, {
"home page carousel has 12 items": () => articlesCount === 12,
})
if (articlesCount === 0) {
return
}
await articles.nth(randomIntBetween(0, articlesCount - 1)).click()
}


async function search(page: Page) {
async function search(page: Page, context: Context) {
await page.goto(`${FRONTEND_BASE_URL}/search?sortby=-views`)
await page.goto(`${FRONTEND_BASE_URL}/search?resource_type_group=program`)
await page.goto(
`${FRONTEND_BASE_URL}/search?resource_type_group=learning_material`,
)
}

async function topics(page: Page) {
async function topics(page: Page, context: Context) {
await page.goto(`${FRONTEND_BASE_URL}/topics`)
}

async function departments(page: Page) {
async function departments(page: Page, context: Context) {
await page.goto(`${FRONTEND_BASE_URL}/departments`)
}

async function units(page: Page) {
async function units(page: Page, context: Context) {
await page.goto(`${FRONTEND_BASE_URL}/units`)
}

async function login(page: Page, context: Context) {
const credential: AuthCredential | undefined = randomItem(credentials)

if (credential == null) {
console.log("Login > skipping because no credentials provided")
return
}

if (!SSO_BASE_URL) {
throw Error("SSO_BASE_URL must be set to run the login flow")
}

if (page.url() === "about:blank") {
await page.goto(FRONTEND_BASE_URL)
}

console.log(`Login > using '${credential.email}'`)

const loginButton = page.getByTestId("login-button-desktop")
await loginButton.first().click()

let loggedIn = false

try {
await loginKeycloak(page, credential, context)
await page.locator('[aria-label="User Menu"]').waitFor({ timeout: 10_000 })
loggedIn = true
console.log("Login > authenticated; user menu visible")
} catch (err) {
console.log(
`Login > failed for '${credential.email}' at URL '${page.url()}': ${err}`,
)
}

check(null, {
"login succeeded": () => loggedIn,
})
}
Comment thread
rhysyngsun marked this conversation as resolved.

const ESCAPED_SSO_URL = escapeRegex(SSO_BASE_URL)

const KEYCLOAK_USERNAME_URL_RE = new RegExp(
`${ESCAPED_SSO_URL}\/realms\/[a-z-]+\/protocol\/openid-connect\/auth.*`,
)
const KEYCLOAK_PASSWORD_URL_RE = new RegExp(
`${ESCAPED_SSO_URL}\/realms\/[a-z-]+\/login-actions\/authenticate.*`,
)
Comment thread
rhysyngsun marked this conversation as resolved.

async function loginKeycloak(
page: Page,
credential: AuthCredential,
context: Context,
) {
await page.waitForURL(KEYCLOAK_USERNAME_URL_RE, {
waitUntil: "load",
})

console.log("Login > Keycloak > on login email page")

const credentialnameInput = await page.locator("input[name=username]")
console.log("Login > Keycloak > found username input")
await credentialnameInput.focus()
await credentialnameInput.fill(credential.email)
console.log("Login > Keycloak > entered email address")

await page.locator("button[type=submit]").click()
console.log("Login > Keycloak > submitting email address")

await page.waitForURL(KEYCLOAK_PASSWORD_URL_RE)
console.log("Login > Keycloak > on login password page")

const passwordInput = await page.locator("input[name=password]")
console.log("Login > Keycloak > found password input")
await passwordInput.focus()
await passwordInput.fill(credential.password, {
force: true,
})
await page.locator("button[type=submit]").click()
console.log("Login > Keycloak > submitting password")
await page.waitForNavigation()

context.loggedIn = true
context.credential = credential
}

async function dashboard(page: Page, context: Context) {
if (!context.loggedIn) {
return
}
await page.goto(`${FRONTEND_BASE_URL}/dashboard`)
}

export async function testFrontend() {
const page = await browser.newPage(BROWSER_CONTEXT_OPTIONS)
const context: Context = {
loggedIn: false,
credential: null,
}

try {
await home(page)
await search(page)
await topics(page)
await departments(page)
await units(page)
// always home page first
await home(page, context)
await search(page, context)
await topics(page, context)
await departments(page, context)
await units(page, context)
await login(page, context)
await dashboard(page, context)
await search(page, context)
await topics(page, context)
await departments(page, context)
await units(page, context)
await home(page, context)
} finally {
await page.close()
}
Expand Down
Loading
Loading