Files
chore/frontend/e2e/mode_parent/user-profile/profile-editing.spec.ts
T
ryan d910a6bc65
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m29s
feat(playwright-cli): add comprehensive test generation, tracing, and video recording documentation
- Introduced detailed documentation for test generation workflow in Playwright CLI, covering planning, generating, and healing tests.
- Added tracing capabilities documentation, including usage, output files, and best practices for debugging and performance analysis.
- Included video recording instructions, emphasizing best practices for capturing browser automation sessions with chapter markers and overlays.
- Implemented user tutorial authentication setup and tutorial tests for parent mode in the E2E testing framework.
- Created JSON files for user tutorial state management, ensuring isolated test environments.
2026-07-17 19:38:02 -04:00

213 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// spec: e2e/plans/user-profile.plan.md
import { test, expect, type APIRequestContext } from '@playwright/test'
import path from 'path'
import { fileURLToPath } from 'url'
import { E2E_EMAIL, E2E_FIRST_NAME } from '../../e2e-constants'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png')
const BACKEND = 'http://localhost:5000'
interface ProfileData {
first_name: string
last_name: string
image_id: string | null
}
async function getProfile(request: APIRequestContext): Promise<ProfileData> {
const res = await request.get(`${BACKEND}/user/profile`)
const data = await res.json()
return { first_name: data.first_name, last_name: data.last_name, image_id: data.image_id ?? null }
}
async function restoreProfile(request: APIRequestContext, profile: ProfileData): Promise<void> {
await request.put(`${BACKEND}/user/profile`, {
data: {
first_name: profile.first_name,
last_name: profile.last_name,
image_id: profile.image_id,
},
})
}
/** Expand a collapsible profile section by its header title and wait for its content. */
async function expandSection(page: import('@playwright/test').Page, title: string): Promise<void> {
const header = page
.locator('.profile-section')
.filter({ has: page.locator('.section-title', { hasText: title }) })
.locator('.section-header')
const expanded = await header.getAttribute('aria-expanded').catch(() => 'false')
if (expanded === 'false') {
await header.click()
}
await page.locator(`#section-${title.toLowerCase()}`).waitFor({ state: 'visible' })
}
/** Navigate to /parent/profile and wait for the form to finish loading. */
async function gotoProfile(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/parent/profile')
// EntityEditForm hides the form behind v-if while loading=true; wait for it to render.
await expect(page.getByLabel('First Name')).toBeVisible({ timeout: 10000 })
// Expand sections that are collapsed by default so their fields/buttons are reachable.
await expandSection(page, 'Account')
await expandSection(page, 'Notifications')
}
test.describe('User Profile editing', () => {
test.describe.configure({ mode: 'serial' })
let originalProfile: ProfileData
test.beforeEach(async ({ request }) => {
originalProfile = await getProfile(request)
})
test.afterEach(async ({ request }) => {
await restoreProfile(request, originalProfile)
})
test('Profile page loads with correct data', async ({ page }) => {
await gotoProfile(page)
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
await expect(page.getByLabel('Last Name')).toHaveValue('Tester')
await expect(page.getByLabel('Email Address')).toHaveValue(E2E_EMAIL)
await expect(page.getByLabel('Email Address')).toBeDisabled()
})
test('Back button navigates back', async ({ page }) => {
await page.goto('/parent')
// Navigate in-app via the profile dropdown so Vue Router has a real history entry.
await page.getByRole('button', { name: 'Parent menu' }).click()
await page.getByRole('menuitem', { name: 'Profile' }).click()
await expect(page).toHaveURL('/parent/profile')
// The profile view shows a header Back button (auto-save form has no Cancel).
await page.getByRole('button', { name: 'Back' }).click()
await expect(page).toHaveURL('/parent')
})
test('Name changes auto-save on blur', async ({ page, request }) => {
await gotoProfile(page)
await page.getByLabel('First Name').fill('UpdatedE2E')
await page.getByLabel('Last Name').fill('UpdatedTester')
await page.getByLabel('Last Name').blur()
// Wait for the auto-save PUT to complete and verify persistence via API.
await expect
.poll(async () => {
const profile = await getProfile(request)
return profile.first_name === 'UpdatedE2E' && profile.last_name === 'UpdatedTester'
})
.toBe(true)
// Reloading the profile page shows the persisted values.
await gotoProfile(page)
await expect(page.getByLabel('First Name')).toHaveValue('UpdatedE2E')
await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester')
})
test('Email field is read-only', async ({ page }) => {
await gotoProfile(page)
const emailInput = page.locator('#email')
await expect(emailInput).toBeDisabled()
await expect(emailInput).toHaveValue(E2E_EMAIL)
})
test('Change profile image (built-in)', async ({ page, request }) => {
await gotoProfile(page)
// Wait for images to load
await page.waitForSelector('.selectable-image')
// Pick the second image in the list (index 1), regardless of which is selected
const images = page.locator('.selectable-image')
const count = await images.count()
expect(count).toBeGreaterThan(1)
// Find first image that is NOT currently selected
let targetIndex = -1
for (let i = 0; i < count; i++) {
const cls = await images.nth(i).getAttribute('class')
if (!cls?.includes('selected')) {
targetIndex = i
break
}
}
expect(targetIndex).toBeGreaterThanOrEqual(0)
await images.nth(targetIndex).click()
// Confirm it is now selected
await expect(images.nth(targetIndex)).toHaveClass(/selected/)
// Images auto-save on selection; verify via API.
await expect
.poll(async () => {
const profile = await getProfile(request)
return Boolean(profile.image_id)
})
.toBe(true)
// Re-visit and confirm selection persists
await gotoProfile(page)
await page.waitForSelector('.selectable-image')
const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src')
expect(selectedSrc).toBeTruthy()
})
test('Upload a custom profile image', async ({ page, request }) => {
await gotoProfile(page)
await page.waitForSelector('.selectable-image')
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByRole('button', { name: 'Add from device' }).click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(TEST_IMAGE)
// The uploaded image appears first in the list and is selected
await expect(page.locator('.selectable-image').first()).toHaveClass(/selected/)
// Images auto-save on upload; verify via API.
await expect
.poll(async () => {
const profile = await getProfile(request)
return Boolean(profile.image_id)
})
.toBe(true)
})
test('Change Password shows email-sent modal', async ({ page }) => {
await gotoProfile(page)
await page.getByRole('button', { name: 'Change Password' }).click()
// Modal appears (title can transition quickly from loading to success)
const dialog = page.locator('.modal-dialog')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.modal-title')).toContainText(
/Change Password|Password Change Email Sent|Password Change Failed/,
)
// Eventually becomes the confirmed state
await expect(
dialog.locator('.modal-title', { hasText: 'Password Change Email Sent' }),
).toBeVisible({
timeout: 10000,
})
await expect(dialog.locator('.modal-message')).toContainText(/password change link/i)
await dialog.getByRole('button', { name: 'OK' }).click()
// Modal dismissed, back on the profile page
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
})
})