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(); + }); +});