Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s
- Changed "Skip tour" button to "Cancel" in various tutorial overlays. - Updated modal titles from "Confirm Task" to "Confirm Penalty" where applicable. - Modified tutorial step call-to-action labels from "Got it" and "Next" to "Continue" for consistency. - Added tests for new tutorial overlay behavior and button interactions. - Refactored tutorial controller to allow ignoring tutorial enabled state in step visibility checks. - Enhanced user profile tutorial restart functionality with appropriate modal messages. - Updated user profile and task confirmation dialog components to reflect new titles and messages. - Adjusted configuration files for local development environments.
445 lines
15 KiB
TypeScript
445 lines
15 KiB
TypeScript
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 "Cancel" 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 })
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|