diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e77d97a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,115 @@
+name: CI/CD Pipeline
+
+on:
+ push:
+ branches: [master, main]
+ pull_request:
+ branches: [master, main]
+
+jobs:
+ lint-and-test:
+ name: Lint and Test
+ runs-on: ubuntu-latest
+
+ defaults:
+ run:
+ working-directory: maternal-web
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: maternal-web/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run linter
+ run: npm run lint
+
+ - name: Run unit tests
+ run: npm run test -- --ci --coverage
+
+ - name: Upload coverage reports
+ uses: codecov/codecov-action@v4
+ with:
+ directory: maternal-web/coverage
+ flags: frontend
+ fail_ci_if_error: false
+
+ e2e-tests:
+ name: E2E Tests
+ runs-on: ubuntu-latest
+ needs: lint-and-test
+
+ defaults:
+ run:
+ working-directory: maternal-web
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: maternal-web/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Playwright browsers
+ run: npx playwright install --with-deps chromium
+
+ - name: Run E2E tests
+ run: npm run test:e2e -- --project=chromium
+ env:
+ CI: true
+
+ - name: Upload Playwright report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-report
+ path: maternal-web/playwright-report/
+ retention-days: 30
+
+ build:
+ name: Build Application
+ runs-on: ubuntu-latest
+ needs: lint-and-test
+
+ defaults:
+ run:
+ working-directory: maternal-web
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: maternal-web/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build application
+ run: npm run build
+
+ - name: Upload build artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: build
+ path: maternal-web/.next/
+ retention-days: 7
diff --git a/maternal-web/components/common/__tests__/LoadingFallback.test.tsx b/maternal-web/components/common/__tests__/LoadingFallback.test.tsx
new file mode 100644
index 0000000..9b68026
--- /dev/null
+++ b/maternal-web/components/common/__tests__/LoadingFallback.test.tsx
@@ -0,0 +1,34 @@
+import { render } from '@testing-library/react'
+import { LoadingFallback } from '../LoadingFallback'
+
+describe('LoadingFallback', () => {
+ it('renders without crashing for page variant', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('renders without crashing for card variant', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('renders without crashing for list variant', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('renders without crashing for chart variant', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('renders without crashing for chat variant', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('defaults to page variant when no variant is specified', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+})
diff --git a/maternal-web/jest.config.js b/maternal-web/jest.config.js
index 8018874..f89e70a 100644
--- a/maternal-web/jest.config.js
+++ b/maternal-web/jest.config.js
@@ -7,10 +7,11 @@ const createJestConfig = nextJest({
// Add any custom config to be passed to Jest
const customJestConfig = {
- setupFilesAfterEnv: ['/jest.setup.js'],
+ setupFilesAfterEnv: ['/jest.setup.ts'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '/$1',
+ '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
collectCoverageFrom: [
'app/**/*.{js,jsx,ts,tsx}',
diff --git a/maternal-web/jest.setup.ts b/maternal-web/jest.setup.ts
new file mode 100644
index 0000000..a68c263
--- /dev/null
+++ b/maternal-web/jest.setup.ts
@@ -0,0 +1,42 @@
+import '@testing-library/jest-dom'
+
+// Mock Next.js router
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: jest.fn(),
+ replace: jest.fn(),
+ prefetch: jest.fn(),
+ back: jest.fn(),
+ pathname: '/',
+ query: {},
+ asPath: '/',
+ }),
+ usePathname: () => '/',
+ useSearchParams: () => new URLSearchParams(),
+}))
+
+// Mock window.matchMedia
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+})
+
+// Mock IntersectionObserver
+global.IntersectionObserver = class IntersectionObserver {
+ constructor() {}
+ disconnect() {}
+ observe() {}
+ takeRecords() {
+ return []
+ }
+ unobserve() {}
+}
diff --git a/maternal-web/lib/api/__tests__/tracking.test.ts b/maternal-web/lib/api/__tests__/tracking.test.ts
new file mode 100644
index 0000000..80682f4
--- /dev/null
+++ b/maternal-web/lib/api/__tests__/tracking.test.ts
@@ -0,0 +1,104 @@
+import { trackingApi } from '../tracking'
+import apiClient from '../client'
+
+jest.mock('../client')
+
+const mockedApiClient = apiClient as jest.Mocked
+
+describe('trackingApi', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('createActivity', () => {
+ it('transforms frontend data to backend format', async () => {
+ const mockActivity = {
+ id: 'act_123',
+ childId: 'chd_456',
+ type: 'feeding',
+ startedAt: '2024-01-01T12:00:00Z',
+ metadata: { amount: 120, type: 'bottle' },
+ loggedBy: 'usr_789',
+ createdAt: '2024-01-01T12:00:00Z',
+ }
+
+ mockedApiClient.post.mockResolvedValue({
+ data: { data: { activity: mockActivity } },
+ } as any)
+
+ const result = await trackingApi.createActivity('chd_456', {
+ type: 'feeding',
+ timestamp: '2024-01-01T12:00:00Z',
+ data: { amount: 120, type: 'bottle' },
+ })
+
+ expect(mockedApiClient.post).toHaveBeenCalledWith(
+ '/api/v1/activities?childId=chd_456',
+ {
+ type: 'feeding',
+ startedAt: '2024-01-01T12:00:00Z',
+ metadata: { amount: 120, type: 'bottle' },
+ notes: undefined,
+ }
+ )
+
+ expect(result).toEqual({
+ ...mockActivity,
+ timestamp: mockActivity.startedAt,
+ data: mockActivity.metadata,
+ })
+ })
+ })
+
+ describe('getActivities', () => {
+ it('transforms backend data to frontend format', async () => {
+ const mockActivities = [
+ {
+ id: 'act_123',
+ childId: 'chd_456',
+ type: 'feeding',
+ startedAt: '2024-01-01T12:00:00Z',
+ metadata: { amount: 120 },
+ },
+ {
+ id: 'act_124',
+ childId: 'chd_456',
+ type: 'sleep',
+ startedAt: '2024-01-01T14:00:00Z',
+ metadata: { duration: 120 },
+ },
+ ]
+
+ mockedApiClient.get.mockResolvedValue({
+ data: { data: { activities: mockActivities } },
+ } as any)
+
+ const result = await trackingApi.getActivities('chd_456', 'feeding')
+
+ expect(mockedApiClient.get).toHaveBeenCalledWith('/api/v1/activities', {
+ params: { childId: 'chd_456', type: 'feeding' },
+ })
+
+ expect(result).toEqual([
+ {
+ id: 'act_123',
+ childId: 'chd_456',
+ type: 'feeding',
+ startedAt: '2024-01-01T12:00:00Z',
+ metadata: { amount: 120 },
+ timestamp: '2024-01-01T12:00:00Z',
+ data: { amount: 120 },
+ },
+ {
+ id: 'act_124',
+ childId: 'chd_456',
+ type: 'sleep',
+ startedAt: '2024-01-01T14:00:00Z',
+ metadata: { duration: 120 },
+ timestamp: '2024-01-01T14:00:00Z',
+ data: { duration: 120 },
+ },
+ ])
+ })
+ })
+})
diff --git a/maternal-web/package-lock.json b/maternal-web/package-lock.json
index b0b5da5..328d5dc 100644
--- a/maternal-web/package-lock.json
+++ b/maternal-web/package-lock.json
@@ -35,6 +35,7 @@
},
"devDependencies": {
"@axe-core/react": "^4.10.2",
+ "@playwright/test": "^1.55.1",
"@testing-library/jest-dom": "^6.9.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
@@ -42,11 +43,13 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
+ "identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",
"jest-axe": "^10.0.0",
"jest-environment-jsdom": "^30.2.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
+ "ts-jest": "^29.4.4",
"typescript": "^5"
}
},
@@ -3154,6 +3157,22 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.55.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz",
+ "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.55.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -4922,6 +4941,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/bs-logger": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz",
+ "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-json-stable-stringify": "2.x"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/bser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
@@ -6982,6 +7014,45 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/handlebars": {
+ "version": "4.7.8",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
+ "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.2",
+ "source-map": "^0.6.1",
+ "wordwrap": "^1.0.0"
+ },
+ "bin": {
+ "handlebars": "bin/handlebars"
+ },
+ "engines": {
+ "node": ">=0.4.7"
+ },
+ "optionalDependencies": {
+ "uglify-js": "^3.1.4"
+ }
+ },
+ "node_modules/handlebars/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/harmony-reflect": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz",
+ "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==",
+ "dev": true,
+ "license": "(Apache-2.0 OR MPL-1.1)"
+ },
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -7161,6 +7232,19 @@
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"license": "ISC"
},
+ "node_modules/identity-obj-proxy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
+ "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "harmony-reflect": "^1.4.6"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -9350,6 +9434,13 @@
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
+ "node_modules/lodash.memoize": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
+ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -9417,6 +9508,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/make-error": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -9521,6 +9619,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@@ -9609,8 +9717,7 @@
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/next": {
"version": "14.2.0",
@@ -10439,6 +10546,53 @@
"node": ">=8"
}
},
+ "node_modules/playwright": {
+ "version": "1.55.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
+ "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.55.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.55.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
+ "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
+ "devOptional": true,
+ "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==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -12470,6 +12624,85 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/ts-jest": {
+ "version": "29.4.4",
+ "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz",
+ "integrity": "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bs-logger": "^0.2.6",
+ "fast-json-stable-stringify": "^2.1.0",
+ "handlebars": "^4.7.8",
+ "json5": "^2.2.3",
+ "lodash.memoize": "^4.1.2",
+ "make-error": "^1.3.6",
+ "semver": "^7.7.2",
+ "type-fest": "^4.41.0",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "ts-jest": "cli.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": ">=7.0.0-beta.0 <8",
+ "@jest/transform": "^29.0.0 || ^30.0.0",
+ "@jest/types": "^29.0.0 || ^30.0.0",
+ "babel-jest": "^29.0.0 || ^30.0.0",
+ "jest": "^29.0.0 || ^30.0.0",
+ "jest-util": "^29.0.0 || ^30.0.0",
+ "typescript": ">=4.3 <6"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "@jest/transform": {
+ "optional": true
+ },
+ "@jest/types": {
+ "optional": true
+ },
+ "babel-jest": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jest-util": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ts-jest/node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ts-jest/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -12586,6 +12819,20 @@
"node": ">=14.17"
}
},
+ "node_modules/uglify-js": {
+ "version": "3.19.3",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
+ "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "bin": {
+ "uglifyjs": "bin/uglifyjs"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -13115,6 +13362,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/workbox-background-sync": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
diff --git a/maternal-web/package.json b/maternal-web/package.json
index 6dc6e2f..bbd7486 100644
--- a/maternal-web/package.json
+++ b/maternal-web/package.json
@@ -9,7 +9,10 @@
"lint": "next lint",
"test": "jest",
"test:watch": "jest --watch",
- "test:coverage": "jest --coverage"
+ "test:coverage": "jest --coverage",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
+ "test:e2e:headed": "playwright test --headed"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -39,6 +42,7 @@
},
"devDependencies": {
"@axe-core/react": "^4.10.2",
+ "@playwright/test": "^1.55.1",
"@testing-library/jest-dom": "^6.9.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
@@ -46,11 +50,13 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
+ "identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",
"jest-axe": "^10.0.0",
"jest-environment-jsdom": "^30.2.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
+ "ts-jest": "^29.4.4",
"typescript": "^5"
}
}
diff --git a/maternal-web/playwright.config.ts b/maternal-web/playwright.config.ts
new file mode 100644
index 0000000..d7156d0
--- /dev/null
+++ b/maternal-web/playwright.config.ts
@@ -0,0 +1,46 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: 'html',
+ use: {
+ baseURL: 'http://localhost:3030',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ {
+ name: 'firefox',
+ use: { ...devices['Desktop Firefox'] },
+ },
+ {
+ name: 'webkit',
+ use: { ...devices['Desktop Safari'] },
+ },
+ // Mobile viewports
+ {
+ name: 'Mobile Chrome',
+ use: { ...devices['Pixel 5'] },
+ },
+ {
+ name: 'Mobile Safari',
+ use: { ...devices['iPhone 12'] },
+ },
+ ],
+
+ webServer: {
+ command: 'npm run dev',
+ url: 'http://localhost:3030',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120000,
+ },
+});
diff --git a/maternal-web/tests/README.md b/maternal-web/tests/README.md
new file mode 100644
index 0000000..2780baa
--- /dev/null
+++ b/maternal-web/tests/README.md
@@ -0,0 +1,145 @@
+# Testing Guide
+
+This document describes the testing setup and best practices for the Maternal Web application.
+
+## Test Structure
+
+```
+maternal-web/
+├── components/
+│ └── **/__tests__/ # Component unit tests
+├── lib/
+│ └── **/__tests__/ # Library/utility unit tests
+├── tests/
+│ └── e2e/ # End-to-end tests
+├── jest.config.js # Jest configuration
+├── jest.setup.ts # Jest setup file
+└── playwright.config.ts # Playwright configuration
+```
+
+## Running Tests
+
+### Unit Tests (Jest + React Testing Library)
+
+```bash
+# Run all unit tests
+npm test
+
+# Run tests in watch mode
+npm run test:watch
+
+# Run tests with coverage
+npm run test:coverage
+```
+
+### E2E Tests (Playwright)
+
+```bash
+# Run all E2E tests
+npm run test:e2e
+
+# Run E2E tests with UI
+npm run test:e2e:ui
+
+# Run E2E tests in headed mode (see browser)
+npm run test:e2e:headed
+```
+
+## Writing Tests
+
+### Unit Tests
+
+Unit tests should be placed in `__tests__` directories next to the code they test.
+
+Example component test:
+
+```typescript
+import { render, screen } from '@testing-library/react'
+import { MyComponent } from '../MyComponent'
+
+describe('MyComponent', () => {
+ it('renders correctly', () => {
+ render()
+ expect(screen.getByText('Test')).toBeInTheDocument()
+ })
+})
+```
+
+### E2E Tests
+
+E2E tests should be placed in `tests/e2e/` directory.
+
+Example E2E test:
+
+```typescript
+import { test, expect } from '@playwright/test';
+
+test('should navigate to page', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('h1')).toContainText('Welcome');
+});
+```
+
+## Coverage Thresholds
+
+The project maintains the following coverage thresholds:
+
+- Branches: 70%
+- Functions: 70%
+- Lines: 70%
+- Statements: 70%
+
+## CI/CD Integration
+
+Tests run automatically on:
+- Every push to `master` or `main` branches
+- Every pull request
+
+The CI pipeline:
+1. Runs linting
+2. Runs unit tests with coverage
+3. Runs E2E tests (Chromium only in CI)
+4. Builds the application
+5. Uploads test artifacts
+
+## Best Practices
+
+1. **Write tests for new features** - All new features should include tests
+2. **Test user interactions** - Focus on testing what users see and do
+3. **Keep tests simple** - Each test should test one thing
+4. **Use descriptive test names** - Test names should describe what they test
+5. **Avoid implementation details** - Test behavior, not implementation
+6. **Mock external dependencies** - Use mocks for API calls and external services
+
+## Useful Commands
+
+```bash
+# Run specific test file
+npm test -- MyComponent.test.tsx
+
+# Run tests matching pattern
+npm test -- --testNamePattern="should render"
+
+# Update snapshots
+npm test -- -u
+
+# Debug tests
+node --inspect-brk node_modules/.bin/jest --runInBand
+
+# Generate Playwright test code
+npx playwright codegen http://localhost:3030
+```
+
+## Troubleshooting
+
+### Jest
+
+- **Tests timing out**: Increase timeout with `jest.setTimeout(10000)` in test file
+- **Module not found**: Check `moduleNameMapper` in `jest.config.js`
+- **Async tests failing**: Make sure to `await` async operations and use `async/await` in tests
+
+### Playwright
+
+- **Browser not launching**: Run `npx playwright install` to install browsers
+- **Tests flaky**: Add `await page.waitForLoadState('networkidle')` or explicit waits
+- **Selectors not working**: Use Playwright Inspector with `npx playwright test --debug`
diff --git a/maternal-web/tests/e2e/tracking.spec.ts b/maternal-web/tests/e2e/tracking.spec.ts
new file mode 100644
index 0000000..3e166c6
--- /dev/null
+++ b/maternal-web/tests/e2e/tracking.spec.ts
@@ -0,0 +1,139 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Activity Tracking Flow', () => {
+ test.beforeEach(async ({ page }) => {
+ // Login before each test
+ await page.goto('/login');
+ await page.fill('input[name="email"]', 'andrei@cloudz.ro');
+ await page.fill('input[name="password"]', 'password');
+ await page.click('button[type="submit"]');
+
+ // Wait for redirect to homepage
+ await page.waitForURL('/');
+ });
+
+ test('should navigate to feeding tracker', async ({ page }) => {
+ // Click on feeding quick action
+ await page.click('text=Feeding');
+
+ // Verify we're on the feeding page
+ await expect(page).toHaveURL('/track/feeding');
+ await expect(page.locator('text=Track Feeding')).toBeVisible();
+ });
+
+ test('should navigate to sleep tracker', async ({ page }) => {
+ // Click on sleep quick action
+ await page.click('text=Sleep');
+
+ // Verify we're on the sleep page
+ await expect(page).toHaveURL('/track/sleep');
+ await expect(page.locator('text=Track Sleep')).toBeVisible();
+ });
+
+ test('should navigate to diaper tracker', async ({ page }) => {
+ // Click on diaper quick action
+ await page.click('text=Diaper');
+
+ // Verify we're on the diaper page
+ await expect(page).toHaveURL('/track/diaper');
+ await expect(page.locator('text=Track Diaper Change')).toBeVisible();
+ });
+
+ test('should display today summary on homepage', async ({ page }) => {
+ // Check that Today's Summary section exists
+ await expect(page.locator('text=Today\'s Summary')).toBeVisible();
+
+ // Check that the three metrics are displayed
+ await expect(page.locator('text=Feedings')).toBeVisible();
+ await expect(page.locator('text=Sleep')).toBeVisible();
+ await expect(page.locator('text=Diapers')).toBeVisible();
+ });
+
+ test('should navigate to AI Assistant', async ({ page }) => {
+ // Click on AI Assistant quick action
+ await page.click('text=AI Assistant');
+
+ // Verify we're on the AI Assistant page
+ await expect(page).toHaveURL('/ai-assistant');
+ await expect(page.locator('text=AI Assistant')).toBeVisible();
+ });
+
+ test('should navigate to Analytics', async ({ page }) => {
+ // Click on Analytics quick action
+ await page.click('text=Analytics');
+
+ // Verify we're on the Analytics page
+ await expect(page).toHaveURL('/analytics');
+ });
+});
+
+test.describe('Feeding Tracker', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/login');
+ await page.fill('input[name="email"]', 'andrei@cloudz.ro');
+ await page.fill('input[name="password"]', 'password');
+ await page.click('button[type="submit"]');
+ await page.waitForURL('/');
+
+ // Navigate to feeding tracker
+ await page.goto('/track/feeding');
+ });
+
+ test('should have feeding type options', async ({ page }) => {
+ // Check that feeding type buttons are visible
+ await expect(page.locator('text=Bottle')).toBeVisible();
+ await expect(page.locator('text=Breast')).toBeVisible();
+ await expect(page.locator('text=Solid')).toBeVisible();
+ });
+
+ test('should have save button', async ({ page }) => {
+ await expect(page.locator('button:has-text("Save")')).toBeVisible();
+ });
+});
+
+test.describe('Sleep Tracker', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/login');
+ await page.fill('input[name="email"]', 'andrei@cloudz.ro');
+ await page.fill('input[name="password"]', 'password');
+ await page.click('button[type="submit"]');
+ await page.waitForURL('/');
+
+ // Navigate to sleep tracker
+ await page.goto('/track/sleep');
+ });
+
+ test('should have sleep type options', async ({ page }) => {
+ // Check that sleep type buttons are visible
+ await expect(page.locator('text=Nap')).toBeVisible();
+ await expect(page.locator('text=Night')).toBeVisible();
+ });
+
+ test('should have save button', async ({ page }) => {
+ await expect(page.locator('button:has-text("Save")')).toBeVisible();
+ });
+});
+
+test.describe('Diaper Tracker', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/login');
+ await page.fill('input[name="email"]', 'andrei@cloudz.ro');
+ await page.fill('input[name="password"]', 'password');
+ await page.click('button[type="submit"]');
+ await page.waitForURL('/');
+
+ // Navigate to diaper tracker
+ await page.goto('/track/diaper');
+ });
+
+ test('should have diaper type options', async ({ page }) => {
+ // Check that diaper type buttons are visible
+ await expect(page.locator('text=Wet')).toBeVisible();
+ await expect(page.locator('text=Dirty')).toBeVisible();
+ await expect(page.locator('text=Both')).toBeVisible();
+ });
+
+ test('should have save button', async ({ page }) => {
+ await expect(page.locator('button:has-text("Save")')).toBeVisible();
+ });
+});