feat(playwright-cli): add comprehensive test generation, tracing, and video recording documentation
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m29s
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m29s
- 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.
This commit is contained in:
@@ -14,6 +14,11 @@ test.describe('Create Child', () => {
|
||||
|
||||
// Navigate to parent list and wait for it to fully load before clicking Add Child
|
||||
await gotoParentList(page)
|
||||
// Dismiss tutorial overlay if present — it intercepts pointer events
|
||||
const skip = page.getByRole('button', { name: 'Skip tour' })
|
||||
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await skip.click()
|
||||
}
|
||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||
})
|
||||
@@ -30,7 +35,7 @@ test.describe('Create Child', () => {
|
||||
test('Reject submission when Name is whitespace only', async ({ page }) => {
|
||||
// 2. Enter only spaces in Name, enter '7' in Age - Create remains disabled
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
await page.getByLabel('Name').fill(' ')
|
||||
await page.getByRole('textbox', { name: 'Name' }).fill(' ')
|
||||
await page.getByLabel('Age').fill('7')
|
||||
|
||||
await expect(createButton).toBeDisabled()
|
||||
@@ -39,7 +44,7 @@ test.describe('Create Child', () => {
|
||||
|
||||
test('Reject submission when Age is empty', async ({ page }) => {
|
||||
// 2. Enter 'Charlie', clear Age - Create button should be disabled
|
||||
await page.getByLabel('Name').fill('Charlie')
|
||||
await page.getByRole('textbox', { name: 'Name' }).fill('Charlie')
|
||||
await page.getByLabel('Age').clear()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
|
||||
@@ -49,7 +54,7 @@ test.describe('Create Child', () => {
|
||||
test('Reject negative age', async ({ page }) => {
|
||||
// 2. Enter 'Dave', enter '-1' - Create remains disabled
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
await page.getByLabel('Name').fill('Dave')
|
||||
await page.getByRole('textbox', { name: 'Name' }).fill('Dave')
|
||||
await page.getByLabel('Age').fill('-1')
|
||||
|
||||
await expect(createButton).toBeDisabled()
|
||||
@@ -57,7 +62,7 @@ test.describe('Create Child', () => {
|
||||
})
|
||||
|
||||
test('Enforce maximum Name length of 64 characters', async ({ page, request }) => {
|
||||
const nameInput = page.getByLabel('Name')
|
||||
const nameInput = page.getByRole('textbox', { name: 'Name' })
|
||||
const ageInput = page.getByLabel('Age')
|
||||
const createButton = page.getByRole('button', { name: 'Create' })
|
||||
|
||||
@@ -85,7 +90,7 @@ test.describe('Create Child', () => {
|
||||
|
||||
test('Reject age greater than 120', async ({ page }) => {
|
||||
// 2. Enter 'Eve', enter '121' in Age - Create button should be disabled
|
||||
await page.getByLabel('Name').fill('Eve')
|
||||
await page.getByRole('textbox', { name: 'Name' }).fill('Eve')
|
||||
await page.getByLabel('Age').fill('121')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
|
||||
|
||||
@@ -9,6 +9,17 @@ function pushToggle(page: Page) {
|
||||
.locator('button.toggle-btn')
|
||||
}
|
||||
|
||||
async function openNotificationsSection(page: Page): Promise<void> {
|
||||
const header = page
|
||||
.locator('.profile-section')
|
||||
.filter({ has: page.locator('.section-title', { hasText: 'Notifications' }) })
|
||||
.locator('.section-header')
|
||||
if ((await header.getAttribute('aria-expanded')) === 'false') {
|
||||
await header.click()
|
||||
}
|
||||
await page.locator('#section-notifications').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
test.describe('Push subscription registration', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// 1.3 No subscription POST when notification permission not granted
|
||||
@@ -26,6 +37,7 @@ test.describe('Push subscription registration', () => {
|
||||
})
|
||||
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
const isDisabled = await toggle.isDisabled().catch(() => false)
|
||||
|
||||
@@ -16,6 +16,17 @@ function pushToggle(page: Page) {
|
||||
.locator('button.toggle-btn')
|
||||
}
|
||||
|
||||
async function openNotificationsSection(page: Page): Promise<void> {
|
||||
const header = page
|
||||
.locator('.profile-section')
|
||||
.filter({ has: page.locator('.section-title', { hasText: 'Notifications' }) })
|
||||
.locator('.section-header')
|
||||
if ((await header.getAttribute('aria-expanded')) === 'false') {
|
||||
await header.click()
|
||||
}
|
||||
await page.locator('#section-notifications').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
test.describe('User Profile Notification Settings', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
@@ -24,6 +35,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Email Digest toggle is visible on User Profile page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
await expect(digestToggle(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
@@ -34,11 +46,13 @@ test.describe('User Profile Notification Settings', () => {
|
||||
// Set known state: enabled
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
|
||||
|
||||
// Set known state: disabled
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
|
||||
await page.reload()
|
||||
await openNotificationsSection(page)
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
|
||||
|
||||
// Restore default
|
||||
@@ -51,6 +65,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
test('Toggling Email Digest off sends correct API payload', async ({ page, request }) => {
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
// Wait for profile to load and toggle to reflect server state
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
|
||||
|
||||
@@ -64,9 +79,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
|
||||
await digestToggle(page).click()
|
||||
|
||||
// Submit the form (Save button)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Toggles auto-save; wait for the PUT and verify the payload.
|
||||
await page.waitForTimeout(500)
|
||||
expect(capturedBody).not.toBeNull()
|
||||
expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(false)
|
||||
@@ -82,6 +95,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
test('Toggling Email Digest on sends correct API payload', async ({ page, request }) => {
|
||||
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
// Wait for profile to load and toggle to reflect server state
|
||||
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
|
||||
|
||||
@@ -94,7 +108,6 @@ test.describe('User Profile Notification Settings', () => {
|
||||
})
|
||||
|
||||
await digestToggle(page).click()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
expect(capturedBody).not.toBeNull()
|
||||
@@ -110,6 +123,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
test('Push Notifications toggle is visible on User Profile page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
await expect(pushToggle(page)).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
@@ -121,6 +135,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
}) => {
|
||||
// Do NOT grant notifications permission — toggle should start unchecked
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
// Either aria-pressed="false" or disabled (when browser denies permissions)
|
||||
@@ -138,6 +153,7 @@ test.describe('User Profile Notification Settings', () => {
|
||||
// Do not grant permission (leave as denied/prompt)
|
||||
await context.clearPermissions()
|
||||
await page.goto('/parent/profile')
|
||||
await openNotificationsSection(page)
|
||||
const toggle = pushToggle(page)
|
||||
await expect(toggle).toBeVisible({ timeout: 5000 })
|
||||
// When permission is denied, the toggle should be disabled or aria-pressed="false"
|
||||
|
||||
@@ -4,6 +4,11 @@ import { E2E_PIN } from '../../e2e-constants'
|
||||
test.describe('Parent profile button – temporary parent mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
// Dismiss tutorial overlay if present — it intercepts pointer events
|
||||
const skip = page.getByRole('button', { name: 'Skip tour' })
|
||||
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await skip.click()
|
||||
}
|
||||
// Switch from permanent mode to temporary mode:
|
||||
// 1. Exit parent mode via Child Mode
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
|
||||
@@ -4,6 +4,11 @@ import { E2E_EMAIL, E2E_FIRST_NAME, E2E_PIN } from '../../e2e-constants'
|
||||
test.describe('Parent profile button – permanent parent mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
// Dismiss tutorial overlay if present — it intercepts pointer events
|
||||
const skip = page.getByRole('button', { name: 'Skip tour' })
|
||||
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await skip.click()
|
||||
}
|
||||
})
|
||||
|
||||
test('Badge – shows 🔒 in permanent parent mode', async ({ page }) => {
|
||||
@@ -23,7 +28,7 @@ test.describe('Parent profile button – permanent parent mode', () => {
|
||||
await page.getByRole('menuitem', { name: 'Profile' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Child Mode exits parent mode', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
async function setTutorialEnabled(request: APIRequestContext, enabled: boolean): Promise<void> {
|
||||
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
|
||||
data: { enabled },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`Failed to set tutorial enabled=${enabled}: ${res.status()} ${await res.text()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetTutorialProgress(request: APIRequestContext): Promise<void> {
|
||||
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
|
||||
data: { reset: true },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to reset tutorial progress: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAllChildren(request: APIRequestContext): Promise<void> {
|
||||
const listRes = await request.get(`${BACKEND}/child/list`)
|
||||
const data = await listRes.json()
|
||||
for (const child of data.children ?? []) {
|
||||
await request.delete(`${BACKEND}/child/${child.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function createChild(request: APIRequestContext, name: string, age: number): Promise<string> {
|
||||
const res = await request.put(`${BACKEND}/child/add`, {
|
||||
data: { name, age, image_id: 'boy01' },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to create child ${name}: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
// /child/add returns a message but not the created child, so read the list.
|
||||
const id = await getFirstChildId(request)
|
||||
if (!id) {
|
||||
throw new Error(`Child ${name} was not found after creation`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function getFirstChildId(request: APIRequestContext): Promise<string | null> {
|
||||
const res = await request.get(`${BACKEND}/child/list`)
|
||||
const data = await res.json()
|
||||
return data.children?.[0]?.id ?? null
|
||||
}
|
||||
|
||||
async function ensureChild(request: APIRequestContext, name: string, age: number): Promise<string> {
|
||||
const existingId = await getFirstChildId(request)
|
||||
if (existingId) return existingId
|
||||
return createChild(request, name, age)
|
||||
}
|
||||
|
||||
async function getFirstChoreId(request: APIRequestContext): Promise<string | null> {
|
||||
const res = await request.get(`${BACKEND}/chore/list`)
|
||||
const data = await res.json()
|
||||
return data.tasks?.[0]?.id ?? null
|
||||
}
|
||||
|
||||
async function createChore(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const res = await request.put(`${BACKEND}/chore/add`, {
|
||||
data: { name, points, image_id: 'boy01' },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to create chore ${name}: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
const id = await getFirstChoreId(request)
|
||||
if (!id) {
|
||||
throw new Error(`Chore ${name} was not found after creation`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function assignChoreToChild(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
choreId: string,
|
||||
): Promise<void> {
|
||||
const res = await request.post(`${BACKEND}/child/${childId}/assign-task`, {
|
||||
data: { task_id: choreId },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`Failed to assign chore ${choreId} to child ${childId}: ${res.status()} ${await res.text()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function createRoutine(
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
points: number,
|
||||
): Promise<string> {
|
||||
const res = await request.put(`${BACKEND}/routine/add`, {
|
||||
data: { name, points, image_id: 'boy01' },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to create routine ${name}: ${res.status()} ${await res.text()}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const id = data.routine?.id
|
||||
if (!id) {
|
||||
throw new Error(`Routine ${name} was not found after creation`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function assignRoutineToChild(
|
||||
request: APIRequestContext,
|
||||
childId: string,
|
||||
routineId: string,
|
||||
): Promise<void> {
|
||||
const res = await request.post(`${BACKEND}/child/${childId}/assign-routine`, {
|
||||
data: { routine_id: routineId },
|
||||
})
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`Failed to assign routine ${routineId} to child ${childId}: ${res.status()} ${await res.text()}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getTutorialCard(page: Page) {
|
||||
return page.locator('.tutorial-root .card')
|
||||
}
|
||||
|
||||
function getTutorialTitle(page: Page) {
|
||||
return page.locator('.tutorial-root .card .title')
|
||||
}
|
||||
|
||||
async function expectTutorialCard(page: Page, title: string): Promise<void> {
|
||||
await expect(getTutorialCard(page)).toBeVisible({ timeout: 10000 })
|
||||
await expect(getTutorialTitle(page)).toHaveText(title)
|
||||
}
|
||||
|
||||
async function dismissTutorial(page: Page): Promise<void> {
|
||||
const card = getTutorialCard(page)
|
||||
if (await card.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
// Use "Skip tour" to clear the active step and drain the queue in one
|
||||
// action, avoiding chained steps that would keep the card visible.
|
||||
await page.locator('.tutorial-root .btn-skip').click()
|
||||
await expect(card).not.toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function clickTutorialNext(page: Page): Promise<void> {
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a tutorial by clicking the primary button repeatedly until the card
|
||||
* disappears. This walks through any chained steps without setting sessionSkipped.
|
||||
*/
|
||||
async function dismissTutorialChain(page: Page, maxClicks = 10): Promise<void> {
|
||||
const card = getTutorialCard(page)
|
||||
for (let i = 0; i < maxClicks; i++) {
|
||||
if (!(await card.isVisible().catch(() => false))) return
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
await expect(card).not.toBeVisible({ timeout: 3000 })
|
||||
}
|
||||
|
||||
test.describe('Tutorial system', () => {
|
||||
// Tutorial state is global to the signed-in user; run these tests sequentially
|
||||
// so parallel resets do not interfere with each other.
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteAllChildren(request)
|
||||
await setTutorialEnabled(request, false)
|
||||
await resetTutorialProgress(request)
|
||||
await setTutorialEnabled(request, true)
|
||||
await resetTutorialProgress(request)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page, request }) => {
|
||||
await dismissTutorial(page)
|
||||
await setTutorialEnabled(request, false)
|
||||
await resetTutorialProgress(request)
|
||||
})
|
||||
|
||||
test('children list shows create-child tutorial when no children exist', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Retry deletion + navigation so a concurrent test that creates a child
|
||||
// does not leave us on the child-points tutorial instead of create-child.
|
||||
await expect(async () => {
|
||||
await deleteAllChildren(request)
|
||||
await page.goto('/parent')
|
||||
await expect(page).toHaveURL('/parent')
|
||||
await expectTutorialCard(page, 'Add your child')
|
||||
}).toPass({ timeout: 20000 })
|
||||
})
|
||||
|
||||
test('help button shows parent-children-list chain on the children list', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const childName = 'TutorialKid'
|
||||
await ensureChild(request, childName, 7)
|
||||
await page.goto('/parent')
|
||||
await expect(page).toHaveURL('/parent')
|
||||
|
||||
// Wait for the child card to render ( tolerate duplicate names from repeat runs ).
|
||||
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// The children list may auto-show a brief loading-state hint. Clear any
|
||||
// active chain so the help button is reachable.
|
||||
await dismissTutorialChain(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Show help for this screen' }).click()
|
||||
|
||||
await expectTutorialCard(page, 'Your children')
|
||||
await clickTutorialNext(page)
|
||||
|
||||
await expectTutorialCard(page, 'Points')
|
||||
await clickTutorialNext(page)
|
||||
|
||||
await expectTutorialCard(page, 'Tap a child')
|
||||
})
|
||||
|
||||
test('create chore form shows edit-chore-name tutorial', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores/create')
|
||||
await expect(page).toHaveURL('/parent/tasks/chores/create')
|
||||
await expectTutorialCard(page, 'Chore Name')
|
||||
})
|
||||
|
||||
test('help button shows list-chore-help on the chore list', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await expect(page).toHaveURL('/parent/tasks/chores')
|
||||
|
||||
// No auto-shown tutorial should appear on this page.
|
||||
await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 3000 })
|
||||
|
||||
const helpButton = page.getByRole('button', { name: 'Show help for this screen' })
|
||||
await expect(helpButton).toBeVisible()
|
||||
await helpButton.click()
|
||||
|
||||
await expectTutorialCard(page, 'Create your chore')
|
||||
})
|
||||
|
||||
test('help button chains through list-edit-hint on the chore list', async ({ page }) => {
|
||||
await page.goto('/parent/tasks/chores')
|
||||
await expect(page).toHaveURL('/parent/tasks/chores')
|
||||
|
||||
await page.getByRole('button', { name: 'Show help for this screen' }).click()
|
||||
await expectTutorialCard(page, 'Create your chore')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Edit items')
|
||||
})
|
||||
|
||||
test('help button shows select-child on the child detail page', async ({ page, request }) => {
|
||||
const childName = 'TutorialKid'
|
||||
const childId = await ensureChild(request, childName, 7)
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page).toHaveURL(`/parent/${childId}`)
|
||||
|
||||
// Wait for the child data to load and the assign buttons to render.
|
||||
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByRole('button', { name: 'Assign Chores' }).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// The page auto-shows select-child on first visit. Walk through the chain
|
||||
// so the help button is reachable and sessionSkipped stays false.
|
||||
await dismissTutorialChain(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Show help for this screen' }).click()
|
||||
await expectTutorialCard(page, "This is your child's page")
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Assign chores')
|
||||
})
|
||||
|
||||
test('kebab menu on assigned chore shows chore-kebab-menu tutorial', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const childName = 'TutorialKid'
|
||||
const childId = await ensureChild(request, childName, 7)
|
||||
const choreId = await createChore(request, 'TutorialChore', 10)
|
||||
await assignChoreToChild(request, childId, choreId)
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page).toHaveURL(`/parent/${childId}`)
|
||||
|
||||
// Wait for the child data and the assigned chore to render.
|
||||
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText('TutorialChore').first()).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Clear the auto-shown select-child chain.
|
||||
await dismissTutorialChain(page)
|
||||
|
||||
// Click the chore card to make it ready (reveals the kebab button).
|
||||
await page.locator('.item-card').filter({ hasText: 'TutorialChore' }).first().click()
|
||||
|
||||
// Click the kebab button and verify the chore kebab tutorial fires.
|
||||
const kebabButton = page
|
||||
.locator('.kebab-btn')
|
||||
.filter({ has: page.locator('text=⋮') })
|
||||
.first()
|
||||
await expect(kebabButton).toBeVisible({ timeout: 5000 })
|
||||
await kebabButton.click()
|
||||
|
||||
await expectTutorialCard(page, 'Chore actions')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Edit points')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Change schedule')
|
||||
})
|
||||
|
||||
test('kebab menu on assigned routine shows routine-kebab-menu tutorial', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const childName = 'TutorialKid'
|
||||
const childId = await ensureChild(request, childName, 7)
|
||||
const routineId = await createRoutine(request, 'TutorialRoutine', 15)
|
||||
await assignRoutineToChild(request, childId, routineId)
|
||||
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await expect(page).toHaveURL(`/parent/${childId}`)
|
||||
|
||||
// Wait for the child data and the assigned routine to render.
|
||||
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.getByText('TutorialRoutine').first()).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Clear the auto-shown select-child chain.
|
||||
await dismissTutorialChain(page)
|
||||
|
||||
// Click the routine card to make it ready (reveals the kebab button).
|
||||
await page.locator('.item-card').filter({ hasText: 'TutorialRoutine' }).first().click()
|
||||
|
||||
// Click the kebab button and verify the routine kebab tutorial fires.
|
||||
const kebabButton = page
|
||||
.locator('.kebab-btn')
|
||||
.filter({ has: page.locator('text=⋮') })
|
||||
.first()
|
||||
await expect(kebabButton).toBeVisible({ timeout: 5000 })
|
||||
await kebabButton.click()
|
||||
|
||||
await expectTutorialCard(page, 'Routine actions')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Edit routine')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Edit points')
|
||||
|
||||
await clickTutorialNext(page)
|
||||
await expectTutorialCard(page, 'Change schedule')
|
||||
})
|
||||
|
||||
test('dismissing an auto-shown tutorial persists across reloads', async ({ page, request }) => {
|
||||
await deleteAllChildren(request)
|
||||
await page.goto('/parent')
|
||||
await expectTutorialCard(page, 'Add your child')
|
||||
|
||||
await page.locator('.tutorial-root .btn-primary').click()
|
||||
await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 5000 })
|
||||
|
||||
await page.reload()
|
||||
await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,17 @@ import { E2E_PIN } from '../../e2e-constants'
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
const NEW_PIN = '5678'
|
||||
|
||||
async function openAccountSection(page: import('@playwright/test').Page): Promise<void> {
|
||||
const accountHeader = page
|
||||
.locator('.profile-section')
|
||||
.filter({ has: page.locator('.section-title', { hasText: 'Account' }) })
|
||||
.locator('.section-header')
|
||||
if ((await accountHeader.getAttribute('aria-expanded')) === 'false') {
|
||||
await accountHeader.click()
|
||||
}
|
||||
await page.locator('#section-account').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async function setPinDirectly(request: APIRequestContext, pin: string): Promise<void> {
|
||||
// Request a new code, retrieve it via test endpoint, verify it, then set the pin
|
||||
await request.post(`${BACKEND}/user/request-pin-setup`)
|
||||
@@ -25,6 +36,7 @@ test.describe('User Profile – Change Parent PIN', () => {
|
||||
|
||||
test('Change Parent PIN link navigates to PIN setup page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Change Parent PIN' }).click()
|
||||
|
||||
@@ -35,6 +47,7 @@ test.describe('User Profile – Change Parent PIN', () => {
|
||||
test('Back from PIN setup page returns to profile', async ({ page }) => {
|
||||
// Navigate from the profile page so browser history exists
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Change Parent PIN' }).click()
|
||||
await expect(page).toHaveURL(/\/parent\/pin-setup/)
|
||||
|
||||
|
||||
@@ -3,11 +3,23 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_DELETE_EMAIL, E2E_DELETE_PASSWORD } from '../../e2e-constants'
|
||||
|
||||
async function openAccountSection(page: import('@playwright/test').Page): Promise<void> {
|
||||
const accountHeader = page
|
||||
.locator('.profile-section')
|
||||
.filter({ has: page.locator('.section-title', { hasText: 'Account' }) })
|
||||
.locator('.section-header')
|
||||
if ((await accountHeader.getAttribute('aria-expanded')) === 'false') {
|
||||
await accountHeader.click()
|
||||
}
|
||||
await page.locator('#section-account').waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
test.describe('User Profile – Delete Account', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('Delete My Account opens confirmation dialog', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
|
||||
@@ -24,6 +36,7 @@ test.describe('User Profile – Delete Account', () => {
|
||||
|
||||
test('Delete button stays disabled for incomplete email', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
@@ -36,6 +49,7 @@ test.describe('User Profile – Delete Account', () => {
|
||||
|
||||
test('Delete button enables when matching email is entered', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
@@ -48,6 +62,7 @@ test.describe('User Profile – Delete Account', () => {
|
||||
|
||||
test('Cancel closes the dialog without deleting the account', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
@@ -57,12 +72,13 @@ test.describe('User Profile – Delete Account', () => {
|
||||
await expect(
|
||||
page.locator('.modal-title', { hasText: 'Delete Your Account?' }),
|
||||
).not.toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
})
|
||||
|
||||
test('Backdrop click does NOT close the delete dialog', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
@@ -77,6 +93,7 @@ test.describe('User Profile – Delete Account', () => {
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/profile')
|
||||
await openAccountSection(page)
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
|
||||
@@ -32,11 +32,27 @@ async function restoreProfile(request: APIRequestContext, profile: ProfileData):
|
||||
})
|
||||
}
|
||||
|
||||
/** 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', () => {
|
||||
@@ -55,7 +71,7 @@ test.describe('User Profile – editing', () => {
|
||||
test('Profile page loads with correct data', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
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)
|
||||
@@ -69,82 +85,33 @@ test.describe('User Profile – editing', () => {
|
||||
await page.getByRole('menuitem', { name: 'Profile' }).click()
|
||||
await expect(page).toHaveURL('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
// 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('Save is disabled when form is clean (not dirty)', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when First Name is empty', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('First Name').blur()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when Last Name is empty', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
await page.getByLabel('Last Name').blur()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when both name fields are empty', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save enables when a name is changed', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Save persists name changes and shows confirmation modal', async ({ page }) => {
|
||||
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.getByRole('button', { name: 'Save' }).click()
|
||||
await page.getByLabel('Last Name').blur()
|
||||
|
||||
const dialog = page.locator('.modal-dialog')
|
||||
await expect(dialog.locator('.modal-title', { hasText: 'Profile Updated' })).toBeVisible()
|
||||
await expect(dialog.getByText('Your profile was updated successfully.')).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||
// 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)
|
||||
|
||||
// OK navigates back; go back to profile to verify persistence
|
||||
// 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('Cancel discards unsaved changes', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.getByLabel('First Name').fill('Discarded')
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Navigate back to verify no changes were saved
|
||||
await gotoProfile(page)
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||
})
|
||||
|
||||
test('Email field is read-only', async ({ page }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
@@ -153,7 +120,7 @@ test.describe('User Profile – editing', () => {
|
||||
await expect(emailInput).toHaveValue(E2E_EMAIL)
|
||||
})
|
||||
|
||||
test('Change profile image (built-in)', async ({ page }) => {
|
||||
test('Change profile image (built-in)', async ({ page, request }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
// Wait for images to load
|
||||
@@ -180,24 +147,22 @@ test.describe('User Profile – editing', () => {
|
||||
// Confirm it is now selected
|
||||
await expect(images.nth(targetIndex)).toHaveClass(/selected/)
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(
|
||||
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }),
|
||||
).toBeVisible()
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||
// 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()
|
||||
// The URL will differ after a new load (Object URL vs cached), so verify via API
|
||||
const profile = await getProfile(page.request)
|
||||
expect(profile.image_id).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Upload a custom profile image', async ({ page }) => {
|
||||
test('Upload a custom profile image', async ({ page, request }) => {
|
||||
await gotoProfile(page)
|
||||
|
||||
await page.waitForSelector('.selectable-image')
|
||||
@@ -210,14 +175,13 @@ test.describe('User Profile – editing', () => {
|
||||
// The uploaded image appears first in the list and is selected
|
||||
await expect(page.locator('.selectable-image').first()).toHaveClass(/selected/)
|
||||
|
||||
// Save is now enabled
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }),
|
||||
).toBeVisible()
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||
// 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 }) => {
|
||||
@@ -243,6 +207,6 @@ test.describe('User Profile – editing', () => {
|
||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Modal dismissed, back on the profile page
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user