import { test, expect, type APIRequestContext, type Page } from '@playwright/test' const BACKEND = 'http://localhost:5000' async function setTutorialEnabled(request: APIRequestContext, enabled: boolean): Promise { 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 { 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 { 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 { 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 { 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 { const existingId = await getFirstChildId(request) if (existingId) return existingId return createChild(request, name, age) } async function getFirstChoreId(request: APIRequestContext): Promise { 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 { 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 { 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 { 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 { 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 { await expect(getTutorialCard(page)).toBeVisible({ timeout: 10000 }) await expect(getTutorialTitle(page)).toHaveText(title) } async function dismissTutorial(page: Page): Promise { 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 { 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 { 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 }) }) test('add-child FAB is disabled while the create-child tutorial is showing', async ({ page, request, }) => { await deleteAllChildren(request) await page.goto('/parent') await expectTutorialCard(page, 'Add your child') const fab = page.locator('.fab') await expect(fab).toBeVisible() await expect(fab).toBeDisabled() // The page should still be on the children list and the tutorial visible. await expect(page).toHaveURL('/parent') await expect(getTutorialCard(page)).toBeVisible() }) test('child form inputs are disabled while the edit-child-name tutorial is showing', async ({ page, }) => { await page.goto('/parent/children/create') await expect(page).toHaveURL('/parent/children/create') await expectTutorialCard(page, "Child's Name") await expect(page.locator('input#name')).toBeDisabled() await expect(page.locator('input#age')).toBeDisabled() // The tutorial card should remain visible after checking the inputs. await expect(getTutorialCard(page)).toBeVisible() }) test('clicking the highlighted Points area does not navigate while child-points tutorial is showing', async ({ page, request, }) => { const childName = 'TutorialPointsKid' await ensureChild(request, childName, 7) await page.goto('/parent') await expect(page).toHaveURL('/parent') // Wait for the child card to render so the children list has finished // loading before we assert on the Points tutorial. await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 }) await expectTutorialCard(page, 'Points') // Small resilience delay so the spotlight blocker is positioned before the // synthetic click reaches it. await page.waitForTimeout(50) const points = page.locator('.card .points').first() await expect(points).toBeVisible() const box = await points.boundingBox() if (!box) throw new Error('Could not resolve points element bounding box') // Click the center of the highlighted points area. The tutorial spotlight // blocker should intercept the click, preventing the card click handler // from navigating to the child detail page. await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) await expect(page).toHaveURL('/parent') await expect(getTutorialCard(page)).toBeVisible() }) })