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:
@@ -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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user