feat: enhance Playwright testing setup with E2E tests, new skills, and improved documentation
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m44s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m44s
- Added E2E test setup in `auth_api.py` with `/e2e-seed` endpoint for database reset and test user creation. - Integrated Playwright for end-to-end testing in the frontend with necessary dependencies in `package.json` and `package-lock.json`. - Created Playwright configuration in `playwright.config.ts` to manage test execution and server setup. - Developed new skills for Playwright best practices, visual regression, smoke test generation, and self-healing tests. - Implemented new test cases for chore creation in `chores-create.smoke.spec.ts` and `chores-create.spec.ts`. - Added page object models for `ChildEditPage` and `LandingPage` to streamline test interactions. - Updated `.gitignore` to exclude Playwright reports and test results. - Enhanced documentation in `copilot-instructions.md` for testing and E2E setup.
This commit is contained in:
60
frontend/vue-app/package-lock.json
generated
60
frontend/vue-app/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "chore-app-frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
@@ -1522,6 +1523,21 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -4924,6 +4940,50 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
|
||||
60
frontend/vue-app/playwright.config.ts
Normal file
60
frontend/vue-app/playwright.config.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
globalSetup: './tests/global-setup.ts',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 1, // Retries help AI "healer" skills see if a failure is flaky
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
|
||||
use: {
|
||||
baseURL: 'https://localhost:5173',
|
||||
trace: 'retain-on-failure', // AI needs this to "see" why a test failed
|
||||
video: 'on-first-retry', // Great for visual debugging
|
||||
screenshot: 'only-on-failure',
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
|
||||
/* Configure projects for different environments */
|
||||
projects: [
|
||||
{
|
||||
name: 'smoke',
|
||||
testMatch: /.*smoke.spec.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'tests/.auth/user.json',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting tests */
|
||||
webServer: [
|
||||
{
|
||||
command: 'npm run dev',
|
||||
url: 'https://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
{
|
||||
command:
|
||||
'.venv\\Scripts\\python.exe -m flask run --host=0.0.0.0 --port=5000 --no-debugger --no-reload',
|
||||
url: 'http://localhost:5000/version',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
cwd: '../../backend',
|
||||
env: {
|
||||
FLASK_APP: 'main.py',
|
||||
FLASK_DEBUG: '1',
|
||||
DB_ENV: 'e2e',
|
||||
DATA_ENV: 'e2e',
|
||||
SECRET_KEY: 'dev-secret-key-change-in-production',
|
||||
REFRESH_TOKEN_EXPIRY_DAYS: '90',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
39
frontend/vue-app/tests/.auth/user.json
Normal file
39
frontend/vue-app/tests/.auth/user.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "eGf3kkzCP5BSOTDLz-_lkzfeBD_j5mzKfWFrJbPD6CY",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1780671228.638382,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzMzg1YWRjNC0yYmI4LTQyMTktOGFiNi05ZjkxNzAyNzA0MjEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzI4OTYxMjh9.zGwBu1uhcEs_aH5MTDQFYKNWb9bjfgdIgSO9YnS3ez8",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://localhost:5173",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1772895228428}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1773068028652}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
40
frontend/vue-app/tests/chores-create.smoke.spec.ts
Normal file
40
frontend/vue-app/tests/chores-create.smoke.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Test: Create a Chore
|
||||
|
||||
test.describe('Chores Tab - Create Chore', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Log in and navigate to Chores tab
|
||||
await page.goto('/')
|
||||
// Assume login is handled by globalSetup and storageState
|
||||
await page.click('button:has-text("Chores")')
|
||||
await expect(page).toHaveURL(/chores/)
|
||||
})
|
||||
|
||||
test('should open create chore form and validate fields', async ({ page }) => {
|
||||
await page.click('button:has-text("Add Chore")')
|
||||
await expect(page.locator('form')).toBeVisible()
|
||||
|
||||
// Try submitting empty form
|
||||
await page.click('button:has-text("Create")')
|
||||
await expect(page.locator('.error')).toBeVisible()
|
||||
|
||||
// Fill valid fields
|
||||
await page.fill('input[name="name"]', 'Take out trash')
|
||||
await page.fill('textarea[name="description"]', 'Take out trash before dinner')
|
||||
await page.fill('input[name="due_date"]', '2099-12-31')
|
||||
await page.selectOption('select[name="assigned_child"]', { index: 0 })
|
||||
await page.selectOption('select[name="reward"]', { index: 0 })
|
||||
|
||||
// Submit
|
||||
await page.click('button:has-text("Create")')
|
||||
await expect(page.locator('.success')).toBeVisible()
|
||||
await expect(page.locator('.chore-list')).toContainText('Take out trash')
|
||||
})
|
||||
|
||||
test('should cancel chore creation', async ({ page }) => {
|
||||
await page.click('button:has-text("Add Chore")')
|
||||
await page.click('button:has-text("Cancel")')
|
||||
await expect(page.locator('form')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
40
frontend/vue-app/tests/chores-create.spec.ts
Normal file
40
frontend/vue-app/tests/chores-create.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// Test: Create a Chore
|
||||
|
||||
test.describe('Chores Tab - Create Chore', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Log in and navigate to Chores tab
|
||||
await page.goto('/')
|
||||
// Assume login is handled by globalSetup and storageState
|
||||
await page.click('button:has-text("Chores")')
|
||||
await expect(page).toHaveURL(/chores/)
|
||||
})
|
||||
|
||||
test('should open create chore form and validate fields', async ({ page }) => {
|
||||
await page.click('button:has-text("Add Chore")')
|
||||
await expect(page.locator('form')).toBeVisible()
|
||||
|
||||
// Try submitting empty form
|
||||
await page.click('button:has-text("Create")')
|
||||
await expect(page.locator('.error')).toBeVisible()
|
||||
|
||||
// Fill valid fields
|
||||
await page.fill('input[name="name"]', 'Take out trash')
|
||||
await page.fill('textarea[name="description"]', 'Take out trash before dinner')
|
||||
await page.fill('input[name="due_date"]', '2099-12-31')
|
||||
await page.selectOption('select[name="assigned_child"]', { index: 0 })
|
||||
await page.selectOption('select[name="reward"]', { index: 0 })
|
||||
|
||||
// Submit
|
||||
await page.click('button:has-text("Create")')
|
||||
await expect(page.locator('.success')).toBeVisible()
|
||||
await expect(page.locator('.chore-list')).toContainText('Take out trash')
|
||||
})
|
||||
|
||||
test('should cancel chore creation', async ({ page }) => {
|
||||
await page.click('button:has-text("Add Chore")')
|
||||
await page.click('button:has-text("Cancel")')
|
||||
await expect(page.locator('form')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
44
frontend/vue-app/tests/global-setup.ts
Normal file
44
frontend/vue-app/tests/global-setup.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
const BACKEND_URL = 'http://localhost:5000'
|
||||
const BASE_URL = 'https://localhost:5173'
|
||||
|
||||
export const E2E_EMAIL = 'e2e@test.com'
|
||||
export const E2E_PASSWORD = 'E2eTestPass1!'
|
||||
export const E2E_PIN = '1234'
|
||||
export const STORAGE_STATE = 'tests/.auth/user.json'
|
||||
|
||||
// Matches PARENT_AUTH_KEY and PARENT_AUTH_EXPIRY_PERSISTENT in src/stores/auth.ts
|
||||
const PARENT_AUTH_KEY = 'parentAuth'
|
||||
const TWO_DAYS_MS = 172_800_000
|
||||
|
||||
export default async function globalSetup() {
|
||||
// Reset all tables and insert a verified test user directly via the backend
|
||||
const seedRes = await fetch(`${BACKEND_URL}/auth/e2e-seed`, { method: 'POST' })
|
||||
if (!seedRes.ok) {
|
||||
throw new Error(`e2e-seed failed: ${seedRes.status} ${await seedRes.text()}`)
|
||||
}
|
||||
|
||||
// Use a real browser to log in so that HttpOnly auth cookies are captured correctly
|
||||
const browser = await chromium.launch()
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true })
|
||||
const page = await context.newPage()
|
||||
|
||||
await page.goto(`${BASE_URL}/auth/login`)
|
||||
await page.fill('#email', E2E_EMAIL)
|
||||
await page.fill('#password', E2E_PASSWORD)
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
// After login the router redirects away from /auth — wait for that navigation
|
||||
await page.waitForURL(/\/(child|parent)/)
|
||||
|
||||
// Inject persistent parent auth into localStorage so tests can access /parent routes
|
||||
// without navigating through the PIN prompt UI
|
||||
await page.evaluate(
|
||||
({ key, expiresAt }) => localStorage.setItem(key, JSON.stringify({ expiresAt })),
|
||||
{ key: PARENT_AUTH_KEY, expiresAt: Date.now() + TWO_DAYS_MS },
|
||||
)
|
||||
|
||||
await context.storageState({ path: STORAGE_STATE })
|
||||
await browser.close()
|
||||
}
|
||||
25
frontend/vue-app/tests/pages/ChildEditPage.ts
Normal file
25
frontend/vue-app/tests/pages/ChildEditPage.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class ChildEditPage {
|
||||
readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async fillName(name: string) {
|
||||
await this.page.getByRole('textbox', { name: 'Name' }).fill(name);
|
||||
}
|
||||
|
||||
async fillAge(age: string) {
|
||||
await this.page.getByLabel('Age').fill(age);
|
||||
}
|
||||
|
||||
async selectImage(imageAlt: string) {
|
||||
await this.page.getByRole('img', { name: imageAlt }).click();
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
}
|
||||
13
frontend/vue-app/tests/pages/LandingPage.ts
Normal file
13
frontend/vue-app/tests/pages/LandingPage.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class LandingPage {
|
||||
readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async clickGetStartedFree() {
|
||||
await this.page.getByRole('button', { name: 'Get Started Free' }).click();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user