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

- 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:
2026-07-17 19:38:02 -04:00
parent 7f0326eff1
commit d910a6bc65
37 changed files with 2754 additions and 182 deletions
+6 -6
View File
@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "G_lq6dT7MCHBDgoW18PimjumaPeyWi7hRSwj2WKA_JI",
"value": "QRDDJrwVfrRB_5Z--i4k48Q4-0kktQnZJelqd9JABBQ",
"domain": "localhost",
"path": "/api/auth",
"expires": 1791672153.30646,
"expires": 1791758861.201632,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJkMWU3MzM3OC04N2I4LTQ2ZjQtYmE2ZC1hMDdhYjE0YjFkOWUiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODM5MDY5NTN9.9GiSopufAFJ7qa9Cu0AhYpUDfempiYayhLaYIA_IoSc",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiIyNjU1MGUxZS00NTBiLTRmMzMtOGI3MC02NjA1MGU2MzZkNzgiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODM5OTM2NjF9.pZegL2iwonftDyVlaADPhHeTPrx7VKkoi8TtDY3Byuk",
"domain": "localhost",
"path": "/",
"expires": 1783906953.306413,
"expires": 1783993661.201585,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1783896153166}"
"value": "{\"type\":\"logout\",\"at\":1783982861064}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1784068953466}"
"value": "{\"expiresAt\":1784155661355}"
}
]
}
+6 -6
View File
@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "fgz_j-G_5UJ-xLf2dJBqcrtiWJkC3amMr8Y90dx_Kdo",
"value": "QCQAmUFxc_xNvuLjIGYO4HL49nrDnWW2K54jTZT3ZBw",
"domain": "localhost",
"path": "/api/auth",
"expires": 1791672153.435032,
"expires": 1791758861.100584,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiYjExZTViNzItNGJlOC00YmY1LTk0ODYtMjE2Nzc5ZGU4Y2ViIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzgzOTA2OTUzfQ.rTfjAYCaaRGAtRrP3eOegQNkIq_o7kTIgvGW9Sn3bO0",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiZjkyN2FjMzItZDE3ZC00MjhhLWI5YTgtYTQ5NjM5YjE4YTgzIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzgzOTkzNjYxfQ.GE6KZ4PXLwLsF8HYZckYwvBruIBMNqVxY-Yg94_QBcU",
"domain": "localhost",
"path": "/",
"expires": 1783906953.43499,
"expires": 1783993661.100535,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1783896153271}"
"value": "{\"type\":\"logout\",\"at\":1783982860894}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1784068953610}"
"value": "{\"expiresAt\":1784155661256}"
}
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "xQQnxda8ujhjlZDBQ2Rl4Xxlh4X2_Kt1e80YSWniAXA",
"domain": "localhost",
"path": "/api/auth",
"expires": 1791760814.863417,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzNWI3ZGVjMS01ODNmLTQ5M2YtYTQ5ZS1mNWM0NzM3YTQ5NzgiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODM5OTU2MTR9.KWJqO7mVswb5IoHsJOOdZSm8iCmYoNVXwixDsQ8lbmI",
"domain": "localhost",
"path": "/",
"expires": 1783995614.863369,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1783984814749}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1784157614996}"
}
]
}
]
}
+6 -6
View File
@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "5-wHHNkUIcILfUZqXFDTiggTIE4F_rhifieI07DQ6WQ",
"value": "zFYZnbIZmC4uH9DcQ3PO8WPPAy_IOts57gvbCMERHvw",
"domain": "localhost",
"path": "/api/auth",
"expires": 1791672151.681425,
"expires": 1791760813.448801,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiIyNTgxZmQwOS05OTMxLTQ3ZTctYmRkYi00MjEwZmQxM2U1MDkiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODM5MDY5NTF9.8qvDLe203vx2YOUnzAoOmBDcT6uov4HhsM5o00UseMg",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzNDkyZjAyMi0yMDdiLTRjMjMtYWFlYy1lMmNjNzc0N2IxYjAiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODM5OTU2MTN9.PM5XaijvAuzEPSY3Mz1DB1jvYTJDeaMhGZnrYukUJ0Q",
"domain": "localhost",
"path": "/",
"expires": 1783906951.68138,
"expires": 1783995613.448755,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1783896151561}"
"value": "{\"type\":\"logout\",\"at\":1783984813329}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1784068951811}"
"value": "{\"expiresAt\":1784157613579}"
}
]
}
+47
View File
@@ -0,0 +1,47 @@
import { test as setup } from '@playwright/test'
import {
STORAGE_STATE_TUTORIAL,
E2E_TUTORIAL_EMAIL,
E2E_TUTORIAL_PASSWORD,
E2E_TUTORIAL_PIN,
} from './e2e-constants'
const BACKEND = 'http://localhost:5000'
setup('authenticate tutorial user', async ({ page }) => {
// Create the isolated tutorial-test user (separate from the main E2E user)
const createRes = await page.request.post(`${BACKEND}/auth/e2e-create-tutorial-user`)
if (!createRes.ok()) {
throw new Error(
`e2e-create-tutorial-user failed: ${createRes.status()} ${await createRes.text()}`,
)
}
await page.goto('/auth/login')
await page.getByLabel('Email address').fill(E2E_TUTORIAL_EMAIL)
await page.getByLabel('Password').fill(E2E_TUTORIAL_PASSWORD)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL(/\/(parent|child)/)
await page.getByRole('button', { name: 'Parent login' }).click()
const pinInput = page.getByPlaceholder('46 digits')
await pinInput.waitFor({ timeout: 5000 })
await pinInput.fill(E2E_TUTORIAL_PIN)
await page.getByLabel('Stay in parent mode on this device').check()
await page.getByRole('button', { name: 'OK' }).click()
await page.waitForURL(/\/parent(\/|$)/)
try {
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
} catch {
await page.screenshot({ path: 'auth-setup-tutorial-parent-fail.png' })
throw new Error(
'Tutorial user parent mode not reached after PIN entry. See auth-setup-tutorial-parent-fail.png for details.',
)
}
await page.context().storageState({ path: STORAGE_STATE_TUTORIAL })
})
+4
View File
@@ -3,6 +3,7 @@ export const STORAGE_STATE_NO_PIN = 'e2e/.auth/user-no-pin.json'
export const STORAGE_STATE_TEMP_PARENT = 'e2e/.auth/user-temp-parent.json'
export const STORAGE_STATE_DELETE = 'e2e/.auth/user-delete.json'
export const STORAGE_STATE_CC = 'e2e/.auth/user-cc.json'
export const STORAGE_STATE_TUTORIAL = 'e2e/.auth/user-tutorial.json'
export const E2E_EMAIL = 'e2e@test.com'
export const E2E_PASSWORD = 'E2eTestPass1!'
export const E2E_PIN = '1234'
@@ -13,3 +14,6 @@ export const E2E_DELETE_PIN = '5678'
export const E2E_CC_EMAIL = 'e2e-cc@test.com'
export const E2E_CC_PASSWORD = 'E2eCCPass1!'
export const E2E_CC_PIN = '3456'
export const E2E_TUTORIAL_EMAIL = 'e2e-tutorial@test.com'
export const E2E_TUTORIAL_PASSWORD = 'E2eTutorialPass1!'
export const E2E_TUTORIAL_PIN = '7890'
@@ -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()
})
})
+17
View File
@@ -4,6 +4,7 @@ import {
STORAGE_STATE_NO_PIN,
STORAGE_STATE_DELETE,
STORAGE_STATE_CC,
STORAGE_STATE_TUTORIAL,
} from './e2e/e2e-constants'
/**
@@ -48,6 +49,12 @@ export default defineConfig({
// Depends on 'setup' because e2e-seed (run in setup) truncates all users first.
{ name: 'setup-delete', testMatch: /auth-delete\.setup\.ts/, dependencies: ['setup'] },
{ name: 'setup-cc', testMatch: /auth-cc\.setup\.ts/, dependencies: ['setup'] },
{
name: 'setup-tutorial',
testMatch: /auth-tutorial\.setup\.ts/,
// Depends on setup because e2e-seed truncates all users first.
dependencies: ['setup'],
},
{
// Bucket A: child-options tests — run before create-child so that
@@ -170,6 +177,15 @@ export default defineConfig({
testMatch: [/mode_parent\/user-profile\/delete-account\.spec\.ts/],
},
{
// Bucket: tutorial system tests — uses an isolated user so tutorial
// enabled/progress state never interferes with other buckets.
name: 'chromium-tutorial',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_TUTORIAL },
dependencies: ['setup-tutorial'],
testMatch: [/mode_parent\/tutorial\/.+\.spec\.ts/],
},
{
name: 'chromium-tasks-rewards',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
@@ -186,6 +202,7 @@ export default defineConfig({
/mode_parent\/chore-scheduler\//,
/mode_parent\/notifications\//,
/mode_parent\/routines\//,
/mode_parent\/tutorial\//,
],
},
@@ -58,9 +58,24 @@ const bodyStyle = computed(() => {
async function measureHeight() {
await nextTick()
if (contentRef.value) {
bodyHeight.value = contentRef.value.scrollHeight
if (!contentRef.value || !innerRef.value) return
// The outer body is currently collapsed (max-height:0 / visibility:hidden),
// so scrollHeight would read 0. Briefly unclamp it off-screen to measure the
// real content height, then restore the closed styles.
const el = contentRef.value
const original = {
maxHeight: el.style.maxHeight,
visibility: el.style.visibility,
position: el.style.position,
}
el.style.maxHeight = 'none'
el.style.visibility = 'hidden'
el.style.position = 'absolute'
const height = innerRef.value.scrollHeight
el.style.maxHeight = original.maxHeight
el.style.visibility = original.visibility
el.style.position = original.position
bodyHeight.value = height
}
watch(isOpen, (open) => {
@@ -293,6 +293,7 @@ function onImageChange(id: string | null) {
uploadLocalImage()
} else {
localImageFile.value = null
imageId.value = id
saveImage(id)
}
}
+2 -2
View File
@@ -604,7 +604,7 @@ export const stepRegistry: Record<string, StepDef> = {
placement: 'auto',
next: 'chore-schedule',
ctaLabel: 'Next',
anchorSelector: '[data-tutorial="chore-edit-points"]',
anchorSelector: '[data-tutorial~="chore-edit-points"]',
},
'chore-schedule': {
id: 'chore-schedule',
@@ -666,7 +666,7 @@ export const stepRegistry: Record<string, StepDef> = {
placement: 'auto',
next: 'routine-schedule',
ctaLabel: 'Next',
anchorSelector: '[data-tutorial="routine-edit-points"]',
anchorSelector: '[data-tutorial~="routine-edit-points"]',
},
'routine-schedule': {
id: 'routine-schedule',
+1 -60
View File
@@ -1,65 +1,6 @@
{
"status": "failed",
"failedTests": [
"ef79c4d59112f5749637-c6693163663a7bc4af47",
"3949411e68481643919b-e327f34655d14e94d19b",
"fec17c26f90fca53cae9-b0219eaf807eb93484de",
"539fbf55106c07a0b0af-4dbe4ab6bd5b824248fe",
"a0ff00fe9db1b94ee976-70bd0af5bd4dc0fc90f8",
"60daab3ab23c5105aded-4ff2d3abc6221e754043",
"573422bc1cb767632e56-3b881fd32c8dca14d993",
"a0ede9b164ff4874e0ea-1430723f42605dce5096",
"73e956d909565ee0ca8d-2027a05776ace22fcea7",
"7aed7c2f6df74087d7cf-768260ba8f7794e89556",
"f29a758461fac766bf43-480f0ca0072bcc8fb76f",
"fee2e0be313c94e5185c-2badde8a28abc5c92483",
"09d158a9a9fa648fe02b-834e154496de68909d7e",
"57f43742bac53b67778c-267067f10b8182dd62d2",
"d687e3783b3095baa295-34bf92454984a3e87508",
"af256c240cf1a2fb3f6b-194c319d22a4af671ff8",
"c71403f444e9a906f110-e6ecd6bb63b4ec5a5a25",
"d6f8839cd56b790be4c0-34272e575f57bb690613",
"252e6efd0a4ebdeaacb8-a975d50326943969dc39",
"b3bc53914d8153862f2d-cf454c9d14c4d890a799",
"0445e4f0b57f766a6451-f4a2bd69ecb1b7eff202",
"1aca78671e91610cfc9f-6588e52abfbc15f784e0",
"b774d3e2b6f2ab46c8be-712d31bbc4fd0bd84af0",
"cce20a13127be7ea2249-a1e21277b88115dec270",
"03642ed65289ed2617e8-8509dd8e7ffa8afb8dd4",
"5a9cc0eb7bb05d0304c1-ccef440cfe201a210d22",
"f962074ab28ffe10b237-ea88306db2c45f2140d7",
"6c63717f825d79d1f694-1c19b7cf35610bc938bd",
"b9f570289262774d1452-2cea4ebc13e842e0985f",
"198b299b4340f34d5cd8-1382ca783eb1a0df2f90",
"2fc044a25384fd9f0d2e-e5dc2182f906ab69b27e",
"1fe10bf5a089bf03c3d4-54a8b73063048512c9a8",
"88fb6b12140d9598bca5-63e11902b68d2508ccd1",
"9196a99e5e3448680c82-30e720656820ccdbdae8",
"4cf8bbd68887cddb5d7d-8ae6e42f7fc7d3fa7e6a",
"5da8774fa40a8b041983-096e580bb83c023e014d",
"f023cabcfbd72e9c251e-766e068ad9eb2a388263",
"29cf2a7310bfafff8608-9edd7583e0e756876a86",
"d8a456b031baa1625a54-0c1555e61dea4396c276",
"abb177b0248c39107c83-969aa0015db0fc99d5ee",
"0d5d17a469d01de6a6b0-65be18163a6824f56957",
"0d5d17a469d01de6a6b0-69e9aaee75fe0e476257",
"0d5d17a469d01de6a6b0-98cb437eec17db3eeb0d",
"0d5d17a469d01de6a6b0-211b11979089425fba87",
"0d5d17a469d01de6a6b0-cb56e46a1465364b8983",
"0d5d17a469d01de6a6b0-5787b1c19c5c3b93cbb4",
"22fbaea7d0c75fbdca8e-4aaa785e7a4dfdc6c43a",
"d450fb075a7292ee9d55-51b084418c21b19c1e90",
"367d416bc67f2212f2ea-d7b6f60f5a9c28db1e79",
"e7caec8f355ec9da3afb-126bac0fba5fb25bb468",
"68f4a83ea53b695d9889-c260f9d095c72937b8d6",
"4a81e53ab660f2d9ca43-237bf2b7adc9bcde6cd8",
"4a81e53ab660f2d9ca43-d60881fdd2c89e327569",
"4a524ee12c5a95d8b699-fa48b54e49f31839e5a8",
"f7f03fab22cee8cdd386-53724737f77985005257",
"f7f03fab22cee8cdd386-9e5db08c6f11001430bf",
"05c60e5489894b04b806-a583933cd36dbec0fbc8",
"1aeb41645bc07733ddab-0bfabbf02a8ee16b1c21",
"1aeb41645bc07733ddab-63443be391ff2e2e6a9a",
"cc1bb6b0a79d3836a965-6ecd8fb21115aebcda0d"
"dee24a66635148f43df6-c43480256bf17c8988e1"
]
}