Add end-to-end tests for parent item management
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m31s

- Implement tests for creating, editing, and deleting chores, kindness acts, and penalties.
- Add tests to verify conversion of default items to user items and restoration of system defaults upon deletion.
- Ensure proper cancellation of creation and editing actions.
- Create a comprehensive plan document outlining the test scenarios and expected behaviors.
This commit is contained in:
2026-03-12 12:22:37 -04:00
parent accf596bd7
commit f250c42e5e
32 changed files with 1995 additions and 197 deletions

View File

@@ -0,0 +1,16 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
import { STORAGE_STATE_NO_PIN } from '../../e2e-constants'
test.use({ storageState: STORAGE_STATE_NO_PIN })
test.describe('Create Child', () => {
test('Add Child FAB is hidden when parent auth is expired', async ({ page }) => {
// Navigate to app root - with no parent auth, router redirects to /child
await page.goto('/')
// expect: the 'Add Child' FAB is NOT visible (not in parent mode)
await expect(page.getByRole('button', { name: 'Add Child' })).not.toBeVisible()
})
})

View File

@@ -0,0 +1,157 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const TEST_IMAGE = path.join(__dirname, '../../../../../resources/logo/star_only.png')
async function deleteNamedChildren(request: any, names: string[]) {
const res = await request.get('/api/child/list')
const data = await res.json()
for (const child of data.children ?? []) {
if (names.includes(child.name)) {
await request.delete(`/api/child/${child.id}`)
}
}
}
async function deleteAllChildren(request: any) {
const res = await request.get('/api/child/list')
const data = await res.json()
for (const child of data.children ?? []) {
await request.delete(`/api/child/${child.id}`)
}
}
test.describe('Create Child', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
})
test('Create a child with name and age only', async ({ page, request }) => {
await deleteNamedChildren(request, ['Alice'])
// 1. Navigate to app root - router redirects to /parent (children list) when parent-authenticated
await page.goto('/')
await expect(page).toHaveURL('/parent')
// 2. Click the 'Add Child' FAB
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
const nameInput = page.getByLabel('Name')
const ageInput = page.getByLabel('Age')
const createButton = page.getByRole('button', { name: 'Create' })
await expect(nameInput).toBeVisible()
await expect(ageInput).toBeVisible()
// Submit should be disabled until all required fields are valid.
await expect(createButton).toBeDisabled()
// 3. Enter 'Alice' in the Name field
await nameInput.fill('Alice')
await expect(createButton).toBeDisabled()
// 4. Enter '8' in the Age field
await ageInput.fill('8')
// 5. Leave Image as default and click Create
// Use toPass() to handle SSE-triggered form resets from parallel tests
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
await deleteNamedChildren(request, ['Alice'])
await nameInput.fill('Alice')
await ageInput.fill('8')
await expect(createButton).toBeEnabled()
await createButton.click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
await expect(page.locator('.error')).not.toBeVisible()
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Alice')).toBeVisible()
})
test('Create a child via the inline Create button in empty state', async ({ page, request }) => {
await deleteAllChildren(request)
// 1. Navigate to app root - router redirects to /parent (children list)
await page.goto('/')
await expect(page.getByText('No children')).toBeVisible()
await expect(page.getByRole('button', { name: 'Create' })).toBeVisible()
// 2. Click the inline 'Create' button
await page.getByRole('button', { name: 'Create' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
// 3. Enter 'Bob' and '10', then submit
const nameInput = page.getByLabel('Name')
const ageInput = page.getByLabel('Age')
const createButton = page.getByRole('button', { name: 'Create' })
// Submit should be disabled until all required fields are valid
await expect(createButton).toBeDisabled()
await nameInput.fill('Bob')
await expect(createButton).toBeDisabled()
await ageInput.fill('10')
// Handle async form initialization race and SSE-triggered form resets
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
await deleteNamedChildren(request, ['Bob'])
await nameInput.fill('Bob')
await ageInput.fill('10')
await expect(createButton).toBeEnabled()
await createButton.click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
await expect(page.locator('.error')).not.toBeVisible()
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Bob')).toBeVisible()
})
test('Create a child with a custom uploaded image', async ({ page, request }) => {
await deleteNamedChildren(request, ['Grace'])
// 1. Navigate to app root - router redirects to /parent (children list)
await page.goto('/')
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
// 2. Enter 'Grace' and '6'
const nameInput = page.getByLabel('Name')
const ageInput = page.getByLabel('Age')
const createButton = page.getByRole('button', { name: 'Create' })
// Submit should be disabled until all required fields are valid
await expect(createButton).toBeDisabled()
await nameInput.fill('Grace')
await expect(createButton).toBeDisabled()
await ageInput.fill('6')
// 3. Upload a local image file via 'Add from device'
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByRole('button', { name: 'Add from device' }).click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(TEST_IMAGE)
// Handle async form initialization race and SSE-triggered form resets
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
await deleteNamedChildren(request, ['Grace'])
await nameInput.fill('Grace')
await ageInput.fill('6')
await expect(createButton).toBeEnabled()
await createButton.click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
await expect(page.locator('.error')).not.toBeVisible()
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Grace')).toBeVisible()
})
})

View File

@@ -0,0 +1,21 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
test.describe('Create Child', () => {
test('Cancel navigates back without saving', async ({ page }) => {
// 1. Navigate to app root - router redirects to /parent (children list)
await page.goto('/')
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
// 2. Fill in 'Frank' and '9', then click Cancel
await page.getByLabel('Name').fill('Frank')
await page.getByLabel('Age').fill('9')
await page.getByRole('button', { name: 'Cancel' }).click()
// expect: back on /parent and 'Frank' is NOT listed
await expect(page).toHaveURL('/parent')
await expect(page.getByText('Frank')).not.toBeVisible()
})
})

View File

@@ -0,0 +1,108 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
import { STORAGE_STATE } from '../../e2e-constants'
test.describe('Create Child', () => {
test.describe.configure({ mode: 'serial' })
test('New child appears in list without page reload', async ({ page, context, request }) => {
// Clean up 'Hannah' before test
const res = await request.get('/api/child/list')
const data = await res.json()
for (const child of data.children ?? []) {
if (child.name === 'Hannah') {
await request.delete(`/api/child/${child.id}`)
}
}
// 1. Open two browser tabs both on /parent (children list)
await page.goto('/')
await expect(page).toHaveURL('/parent')
const tab2 = await context.newPage()
await tab2.goto('/')
await expect(tab2).toHaveURL('/parent')
// 2. In Tab 1, create child 'Hannah' age '4'
// Use a retry loop: SSE events from parallel tests can reset the form or cancel navigation
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
// Clean up any Hannah created in a previous attempt where navigation was cancelled
const lr = await request.get('/api/child/list')
for (const c of (await lr.json()).children ?? []) {
if (c.name === 'Hannah') await request.delete(`/api/child/${c.id}`)
}
await page.getByLabel('Name').fill('Hannah')
await page.getByLabel('Age').fill('4')
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
await page.getByRole('button', { name: 'Create' }).click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
await expect(page).toHaveURL('/parent')
await expect(page.getByRole('heading', { name: 'Hannah' })).toBeVisible()
// 3. Tab 2 should show 'Hannah' via SSE without a manual refresh
await expect(tab2.getByRole('heading', { name: 'Hannah' })).toBeVisible()
await tab2.close()
})
test('New child appears in child mode list without page reload', async ({
page,
browser,
request,
}) => {
// Clean up 'Hannah' before test
const res = await request.get('/api/child/list')
const data = await res.json()
for (const child of data.children ?? []) {
if (child.name === 'Hannah') {
await request.delete(`/api/child/${child.id}`)
}
}
// 1. Tab 1: parent mode
await page.goto('/')
await expect(page).toHaveURL('/parent')
// 2. Tab 2: isolated browser context so removing parentAuth doesn't affect Tab 1
const childContext = await browser.newContext({ storageState: STORAGE_STATE })
const tab2 = await childContext.newPage()
// Load the app first (to ensure localStorage is seeded from storageState),
// then remove parentAuth so the next navigation boots in child mode.
await tab2.goto('/')
await tab2.evaluate(() => localStorage.removeItem('parentAuth'))
// Full navigation triggers a fresh Vue init — auth store reads no parentAuth
// so the router allows /child
await tab2.goto('/child')
await expect(tab2).toHaveURL('/child')
// 3. In Tab 1, create child 'Hannah' age '4'
// Use a retry loop: SSE events from parallel tests can reset the form or cancel navigation
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
// Clean up any Hannah created in a previous attempt where navigation was cancelled
const lr = await request.get('/api/child/list')
for (const c of (await lr.json()).children ?? []) {
if (c.name === 'Hannah') await request.delete(`/api/child/${c.id}`)
}
await page.getByLabel('Name').fill('Hannah')
await page.getByLabel('Age').fill('4')
await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled()
await page.getByRole('button', { name: 'Create' }).click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
await expect(page).toHaveURL('/parent')
await expect(page.getByRole('heading', { name: 'Hannah' })).toBeVisible()
// 4. Tab 2 (child mode) should show 'Hannah' via SSE without a manual refresh
await expect(tab2.getByRole('heading', { name: 'Hannah' })).toBeVisible()
await childContext.close()
})
})

View File

@@ -0,0 +1,88 @@
// spec: e2e/plans/create-child.plan.md
import { test, expect } from '@playwright/test'
test.describe('Create Child', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
// Navigate to app root - router redirects to /parent (children list) when parent-authenticated
await page.goto('/')
await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
})
test('Reject submission when Name is empty', async ({ page }) => {
// 2. Leave Name empty, enter '7' in Age - Create remains disabled
const createButton = page.getByRole('button', { name: 'Create' })
await page.getByLabel('Age').fill('7')
await expect(createButton).toBeDisabled()
await expect(page).toHaveURL('/parent/children/create')
})
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.getByLabel('Age').fill('7')
await expect(createButton).toBeDisabled()
await expect(page).toHaveURL('/parent/children/create')
})
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.getByLabel('Age').clear()
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
await expect(page).toHaveURL('/parent/children/create')
})
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.getByLabel('Age').fill('-1')
await expect(createButton).toBeDisabled()
await expect(page).toHaveURL('/parent/children/create')
})
test('Enforce maximum Name length of 64 characters', async ({ page, request }) => {
const nameInput = page.getByLabel('Name')
const ageInput = page.getByLabel('Age')
const createButton = page.getByRole('button', { name: 'Create' })
// Use toPass() to handle SSE-triggered form resets from parallel tests
const FULL_NAME = 'A'.repeat(64)
await expect(async () => {
if (new URL(page.url()).pathname === '/parent') return
// Clean up any previously-created 64-A child in case prev attempt created but nav was cancelled
const lr = await request.get('/api/child/list')
for (const c of (await lr.json()).children ?? []) {
if (c.name === FULL_NAME) await request.delete(`/api/child/${c.id}`)
}
// Fill 65 chars — HTML maxlength=64 truncates to 64
await nameInput.fill('A'.repeat(65))
// Verify truncation via non-retrying read (throws if SSE cleared the field)
const truncated = await nameInput.inputValue()
if (truncated !== FULL_NAME)
throw new Error(`maxlength truncation failed: got "${truncated}"`)
await ageInput.fill('5')
await expect(createButton).toBeEnabled()
await createButton.click({ timeout: 2000 })
await expect(page).toHaveURL('/parent', { timeout: 5000 })
}).toPass({ timeout: 20000 })
})
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.getByLabel('Age').fill('121')
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
await expect(page).toHaveURL('/parent/children/create')
})
})