From bd9e92f304d3159a4d72e4cd92b05da8ebfa7c5d Mon Sep 17 00:00:00 2001 From: "(jenkins)" <(jenkins)> Date: Tue, 17 Mar 2026 00:29:39 +0000 Subject: [PATCH 1/5] Configure Playwright for headless CI and add Gitea workflow with container support --- .gitea/workflows/test.yml | 55 ++++++ backend/app/server.js | 10 +- backend/package-lock.json | 133 +++++++++++++- backend/package.json | 3 +- backend/tests/server.test.js | 88 +++++++--- frontend/e2e/app.spec.js | 40 +++++ frontend/package-lock.json | 243 +++++++++++++++++++++++++- frontend/package.json | 6 +- frontend/playwright-report/index.html | 85 +++++++++ frontend/playwright.config.js | 26 +++ frontend/src/__tests__/App.test.jsx | 144 ++++++++++----- frontend/src/setupTests.js | 4 + frontend/test-results/.last-run.json | 8 + frontend/vite.config.js | 4 +- 14 files changed, 761 insertions(+), 88 deletions(-) create mode 100644 .gitea/workflows/test.yml create mode 100644 frontend/e2e/app.spec.js create mode 100644 frontend/playwright-report/index.html create mode 100644 frontend/playwright.config.js create mode 100644 frontend/src/setupTests.js create mode 100644 frontend/test-results/.last-run.json diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..db2951e --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,55 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + backend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: backend/package-lock.json + - name: Install dependencies + run: cd backend && npm ci + - name: Run tests + run: cd backend && npm test + + frontend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + - name: Install dependencies + run: cd frontend && npm ci + - name: Run unit tests + run: cd frontend && npm test -- --run + + e2e-test: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.50.1-noble + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + - name: Install dependencies + run: cd frontend && npm install + - name: Run E2E tests + run: cd frontend && npm run test:e2e diff --git a/backend/app/server.js b/backend/app/server.js index af1ed10..6845e24 100644 --- a/backend/app/server.js +++ b/backend/app/server.js @@ -132,6 +132,10 @@ app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -app.listen(PORT, '0.0.0.0', () => { - console.log(`Line of Sight Backend running on port ${PORT}`); -}); +if (require.main === module) { + app.listen(PORT, '0.0.0.0', () => { + console.log(`Line of Sight Backend running on port ${PORT}`); + }); +} + +module.exports = { app, calculateDestination }; diff --git a/backend/package-lock.json b/backend/package-lock.json index d32c9a4..73027e2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,7 +16,8 @@ }, "devDependencies": { "jest": "^30.3.0", - "nodemon": "^3.1.14" + "nodemon": "^3.1.14", + "supertest": "^7.2.2" } }, "node_modules/@babel/code-frame": { @@ -932,6 +933,27 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1427,6 +1449,12 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1921,6 +1949,15 @@ "node": ">= 0.8" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1969,6 +2006,12 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -2064,6 +2107,16 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -2329,6 +2382,12 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2456,6 +2515,23 @@ "node": ">= 0.6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3677,6 +3753,27 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -4832,6 +4929,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 1769387..e78e840 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ }, "devDependencies": { "jest": "^30.3.0", - "nodemon": "^3.1.14" + "nodemon": "^3.1.14", + "supertest": "^7.2.2" } } diff --git a/backend/tests/server.test.js b/backend/tests/server.test.js index 57463ab..f416089 100644 --- a/backend/tests/server.test.js +++ b/backend/tests/server.test.js @@ -1,42 +1,74 @@ -/** - * Placeholder test file for Line of Sight Backend - * - * TODO: Add real tests for: - * - API endpoint validation - * - Geospatial calculations - * - Database queries - * - Error handling - */ +const request = require('supertest'); +const { app, calculateDestination } = require('../app/server'); + +// Mock pg Pool +jest.mock('pg', () => { + const mPool = { + query: jest.fn(), + on: jest.fn(), + end: jest.fn(), + }; + return { Pool: jest.fn(() => mPool) }; +}); + +const { Pool } = require('pg'); +const pool = new Pool(); describe('Line of Sight Backend', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('calculateDestination', () => { + test('should calculate correct destination for North (0 degrees)', () => { + const start = { lat: 0, lon: 0 }; + const dest = calculateDestination(start.lat, start.lon, 0, 111.12); // ~1 degree North + expect(dest.lat).toBeCloseTo(1, 1); + expect(dest.lon).toBeCloseTo(0, 1); + }); + + test('should calculate correct destination for East (90 degrees)', () => { + const start = { lat: 0, lon: 0 }; + const dest = calculateDestination(start.lat, start.lon, 90, 111.12); // ~1 degree East at equator + expect(dest.lat).toBeCloseTo(0, 1); + expect(dest.lon).toBeCloseTo(1, 1); + }); + }); + describe('GET /api/health', () => { - test('should return 200 OK', () => { - // TODO: Implement health check test - expect(true).toBe(true); + test('should return 200 OK', async () => { + const response = await request(app).get('/api/health'); + expect(response.status).toBe(200); + expect(response.body.status).toBe('ok'); }); }); describe('GET /api/line-of-sight', () => { - test('should return valid response structure', () => { - // TODO: Implement line of sight API test - expect(true).toBe(true); + test('should return valid response structure', async () => { + // Mock the water checks and the main city query + pool.query + .mockResolvedValueOnce({ rows: [{ has_land: true }] }) // Simplified for tests, it actually calls many times + .mockResolvedValue({ rows: [{ id: 1, name: 'London', population: 9000000, country: 'GB', lat: 51.5, lon: -0.1, distance_off_line_km: 0, distance_from_start_km: 0, pos_on_line: 0 }] }); + + const response = await request(app) + .get('/api/line-of-sight') + .query({ lat: 51.5, lon: -0.1, direction: 45, tolerance: 50 }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.conurbations).toBeDefined(); + expect(response.body.data.line_coordinates).toBeDefined(); }); - test('should handle missing parameters', () => { - // TODO: Implement error handling test - expect(true).toBe(true); - }); - }); + test('should handle database errors', async () => { + pool.query.mockRejectedValue(new Error('DB Error')); - describe('Geospatial Calculations', () => { - test('should calculate great circle path', () => { - // TODO: Implement geospatial calculation tests - expect(true).toBe(true); - }); + const response = await request(app) + .get('/api/line-of-sight') + .query({ lat: 51.5, lon: -0.1, direction: 45, tolerance: 50 }); - test('should filter cities within tolerance', () => { - // TODO: Implement tolerance filtering tests - expect(true).toBe(true); + expect(response.status).toBe(500); + expect(response.body.success).toBe(false); }); }); }); diff --git a/frontend/e2e/app.spec.js b/frontend/e2e/app.spec.js new file mode 100644 index 0000000..3ad70f1 --- /dev/null +++ b/frontend/e2e/app.spec.js @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Line of Sight Application', () => { + test('should load the home page and show settings', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('text=Line of Sight Settings')).toBeVisible(); + await expect(page.locator('text=Direction')).toBeVisible(); + }); + + test('should be able to toggle map style', async ({ page }) => { + await page.goto('/'); + const darkButton = page.locator('button:text("Dark")'); + await darkButton.click(); + await expect(darkButton).toHaveClass(/active-style/); + }); + + test('should show results when clicking the search button', async ({ page }) => { + await page.goto('/'); + + // Mock the API response to avoid dependency on backend for E2E if desired, + // but here we let it hit the real backend if it's up. + // For a robust E2E in CI, we usually mock. + await page.route('**/api/line-of-sight*', async route => { + const json = { + success: true, + data: { + conurbations: [ + { id: 1, name: 'Test City', population: 100000, country: 'TS', lat: 0, lon: 0, distance_km: 10, off_line_km: 1 } + ], + line_coordinates: [{ lat: 0, lon: 0 }, { lat: 1, lon: 1 }] + } + }; + await route.fulfill({ json }); + }); + + await page.click('button:text("Show Line of Sight")'); + await expect(page.locator('text=Conurbations Found')).toBeVisible(); + await expect(page.locator('text=Test City')).toBeVisible(); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4c991a5..416924f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,10 +15,11 @@ "react-dom": "^19.2.1" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.1.0", "@vitejs/plugin-react": "^4.3.0", - "jsdom": "^29.0.0", + "happy-dom": "^20.8.4", "vite": "^6.0.0", "vitest": "^3.0.0" } @@ -34,6 +35,8 @@ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", @@ -50,6 +53,8 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": "20 || >=22" } @@ -59,6 +64,8 @@ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", @@ -75,6 +82,8 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": "20 || >=22" } @@ -83,7 +92,9 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/@babel/code-frame": { "version": "7.29.0", @@ -362,6 +373,8 @@ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "css-tree": "^3.0.0" }, @@ -384,6 +397,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" } @@ -403,6 +418,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -426,6 +443,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.1.1" @@ -453,6 +472,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -475,6 +496,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "peerDependencies": { "css-tree": "^3.2.1" }, @@ -499,6 +522,8 @@ "url": "https://opencollective.com/csstools" } ], + "optional": true, + "peer": true, "engines": { "node": ">=20.19.0" } @@ -924,6 +949,8 @@ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, @@ -1076,6 +1103,21 @@ "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==" }, + "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==", + "dev": true, + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3508,6 +3550,15 @@ "resolved": "https://registry.npmjs.org/@types/kdbush/-/kdbush-3.0.5.tgz", "integrity": "sha512-tdJz7jaWFu4nR+8b2B+CdPZ6811ighYylWsu2hpsivapzW058yP0KdfZuNY89IiRe5jbKvBGXN3LQdN2KPXVdQ==" }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/supercluster": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", @@ -3516,6 +3567,21 @@ "@types/geojson": "*" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -3725,6 +3791,8 @@ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "require-from-string": "^2.0.2" } @@ -3879,6 +3947,8 @@ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" @@ -3916,6 +3986,8 @@ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" @@ -3945,7 +4017,9 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/deep-eql": { "version": "5.0.2", @@ -4009,6 +4083,8 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=0.12" }, @@ -4311,6 +4387,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/happy-dom": { + "version": "20.8.4", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.4.tgz", + "integrity": "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==", + "dev": true, + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4352,6 +4466,8 @@ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@exodus/bytes": "^1.6.0" }, @@ -4372,7 +4488,9 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/js-tokens": { "version": "4.0.0", @@ -4385,6 +4503,8 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz", "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@asamuzakjp/dom-selector": "^7.0.2", @@ -4425,6 +4545,8 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": "20 || >=22" } @@ -4550,7 +4672,9 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/mime-db": { "version": "1.52.0", @@ -4628,6 +4752,8 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -4679,6 +4805,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "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==", + "dev": true, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/point-in-polygon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", @@ -4769,6 +4939,8 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -4844,6 +5016,8 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4915,6 +5089,8 @@ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -5028,7 +5204,9 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/tinybench": { "version": "2.9.0", @@ -5095,6 +5273,8 @@ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "tldts-core": "^7.0.26" }, @@ -5106,7 +5286,9 @@ "version": "7.0.26", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/topojson-client": { "version": "3.1.0", @@ -5137,6 +5319,8 @@ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "tldts": "^7.0.5" }, @@ -5149,6 +5333,8 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -5166,10 +5352,18 @@ "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -5373,6 +5567,8 @@ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -5385,6 +5581,8 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20" } @@ -5394,6 +5592,8 @@ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=20" } @@ -5403,6 +5603,8 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", @@ -5428,11 +5630,34 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=18" } @@ -5441,7 +5666,9 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true + "dev": true, + "optional": true, + "peer": true }, "node_modules/yallist": { "version": "3.1.1", diff --git a/frontend/package.json b/frontend/package.json index af076f1..922e031 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,10 +10,11 @@ "react-dom": "^19.2.1" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.1.0", "@vitejs/plugin-react": "^4.3.0", - "jsdom": "^29.0.0", + "happy-dom": "^20.8.4", "vite": "^6.0.0", "vitest": "^3.0.0" }, @@ -21,7 +22,8 @@ "start": "vite", "build": "vite build", "preview": "vite preview", - "test": "vitest" + "test": "vitest", + "test:e2e": "playwright test" }, "browserslist": { "production": [ diff --git a/frontend/playwright-report/index.html b/frontend/playwright-report/index.html new file mode 100644 index 0000000..2e24970 --- /dev/null +++ b/frontend/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js new file mode 100644 index 0000000..7f0761d --- /dev/null +++ b/frontend/playwright.config.js @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'list' : 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + headless: true, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run start', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/frontend/src/__tests__/App.test.jsx b/frontend/src/__tests__/App.test.jsx index 6f268c4..cd812e3 100644 --- a/frontend/src/__tests__/App.test.jsx +++ b/frontend/src/__tests__/App.test.jsx @@ -1,53 +1,109 @@ -/** - * Placeholder test file for Line of Sight Frontend - * - * TODO: Add real tests for: - * - Map component rendering - * - Direction selector functionality - * - API integration - * - User interactions - * - Line of sight visualization - */ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi, describe, test, expect, beforeEach } from 'vitest'; +import App from '../App'; +import apiService from '../services/api'; -describe('Line of Sight Frontend', () => { - describe('App Component', () => { - test('should render map container', () => { - // TODO: Implement component rendering test - expect(true).toBe(true); +// Mock MapLibre GL +vi.mock('maplibre-gl', () => { + const mInstance = { + on: vi.fn(), + off: vi.fn(), + remove: vi.fn(), + addSource: vi.fn(), + removeSource: vi.fn(), + addLayer: vi.fn(), + removeLayer: vi.fn(), + getSource: vi.fn(() => ({ setData: vi.fn() })), + getLayer: vi.fn(), + setLayoutProperty: vi.fn(), + setStyle: vi.fn(), + flyTo: vi.fn(), + jumpTo: vi.fn(), + fitBounds: vi.fn(), + isStyleLoaded: vi.fn(() => true), + setSky: vi.fn(), + setTerrain: vi.fn(), + }; + + return { + default: { + Map: vi.fn(() => mInstance), + Marker: vi.fn(() => ({ + setLngLat: vi.fn().mockReturnThis(), + addTo: vi.fn().mockReturnThis(), + remove: vi.fn().mockReturnThis(), + getElement: vi.fn(() => { + const el = document.createElement('div'); + // Add a simple way to trigger click for tests if needed + return el; + }), + })), + Popup: vi.fn(() => ({ + setLngLat: vi.fn().mockReturnThis(), + setDOMContent: vi.fn().mockReturnThis(), + addTo: vi.fn().mockReturnThis(), + remove: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + })), + LngLatBounds: vi.fn(() => ({ + extend: vi.fn().mockReturnThis(), + })), + MercatorCoordinate: { + fromLngLat: vi.fn(() => ({ x: 0, y: 0, z: 0 })), + } + } + }; +}); + +// Mock apiService +vi.mock('../services/api', () => ({ + default: { + getLineOfSight: vi.fn(), + healthCheck: vi.fn(), + } +})); + +describe('App Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('renders the application title', () => { + render(); + expect(screen.getByText(/Line of Sight Settings/i)).toBeInTheDocument(); + }); + + test('renders initial controls', () => { + render(); + // Use role or more specific text to avoid duplicates + expect(screen.getByText(/Direction \(0-360°\):/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Show Line of Sight/i })).toBeInTheDocument(); + }); + + test('calls API when clicking "Show Line of Sight"', async () => { + apiService.getLineOfSight.mockResolvedValue({ + data: { + success: true, + data: { + conurbations: [], + line_coordinates: [{ lat: 0, lon: 0 }] + } + } }); - test('should display direction selector', () => { - // TODO: Implement direction selector test - expect(true).toBe(true); - }); + render(); + const button = screen.getByRole('button', { name: /Show Line of Sight/i }); + fireEvent.click(button); - test('should handle map click events', () => { - // TODO: Implement click event test - expect(true).toBe(true); + await waitFor(() => { + expect(apiService.getLineOfSight).toHaveBeenCalled(); }); }); - describe('API Integration', () => { - test('should fetch line of sight data', () => { - // TODO: Implement API fetch test - expect(true).toBe(true); - }); - - test('should handle API errors gracefully', () => { - // TODO: Implement error handling test - expect(true).toBe(true); - }); - }); - - describe('UI Components', () => { - test('should toggle map style between light/dark', () => { - // TODO: Implement style toggle test - expect(true).toBe(true); - }); - - test('should display conurbation results table', () => { - // TODO: Implement results table test - expect(true).toBe(true); - }); + test('toggles map style', () => { + render(); + const darkButton = screen.getByRole('button', { name: /Dark/i }); + fireEvent.click(darkButton); + expect(darkButton).toHaveClass('active-style'); }); }); diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js new file mode 100644 index 0000000..5d09c48 --- /dev/null +++ b/frontend/src/setupTests.js @@ -0,0 +1,4 @@ +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +// Global mocks if needed diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json new file mode 100644 index 0000000..33809c2 --- /dev/null +++ b/frontend/test-results/.last-run.json @@ -0,0 +1,8 @@ +{ + "status": "failed", + "failedTests": [ + "a510cc93a867214a2ea0-87d527ee6ab9fa6e55ec", + "a510cc93a867214a2ea0-64f1332b15a123b767f1", + "a510cc93a867214a2ea0-ebcb65ad8c44378dd3e8" + ] +} \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index bbf1f36..c0b100f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -5,7 +5,9 @@ export default defineConfig({ plugins: [react()], test: { globals: true, - environment: 'jsdom', + environment: 'happy-dom', + setupFiles: ['./src/setupTests.js'], + exclude: ['**/e2e/**', '**/node_modules/**'], }, server: { port: 3000, From cac76a2f69e94ab65d15a8232c33b328219879f0 Mon Sep 17 00:00:00 2001 From: "(jenkins)" <(jenkins)> Date: Tue, 17 Mar 2026 00:34:17 +0000 Subject: [PATCH 2/5] Disable npm cache for now. --- .gitea/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index db2951e..d5deb07 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -15,8 +15,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: '24' - cache: 'npm' - cache-dependency-path: backend/package-lock.json + # cache: ''npm'' + # cache-dependency-path: backend/package-lock.json - name: Install dependencies run: cd backend && npm ci - name: Run tests From 228032c913446e87239d7272cace14f5ab294ddc Mon Sep 17 00:00:00 2001 From: "(jenkins)" <(jenkins)> Date: Wed, 1 Apr 2026 11:04:16 +0100 Subject: [PATCH 3/5] Update application ports to 3050 and enhance Playwright tests with WebGL mock --- .claude/settings.local.json | 9 +++ .github/copilot-instructions.md | 70 ++++++++++++++++ CLAUDE.md | 112 ++++++++++++++++++++++++++ README.md | 4 +- docker-compose.yml | 2 +- frontend/Dockerfile | 2 +- frontend/e2e/app.spec.js | 74 +++++++++++++++-- frontend/playwright-report/index.html | 2 +- frontend/playwright.config.js | 12 ++- frontend/test-results/.last-run.json | 8 +- frontend/vite.config.js | 2 +- 11 files changed, 278 insertions(+), 19 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .github/copilot-instructions.md create mode 100644 CLAUDE.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ba2a8ac --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(npm test:*)", + "Bash(npm run:*)", + "Bash(npx playwright:*)" + ] + } +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..7c5164e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,70 @@ +# Copilot Instructions for line-of-sight + +## Purpose +This repository is a full-stack geospatial app: +- `frontend/`: React + Vite + MapLibre UI +- `backend/`: Express API with PostGIS queries +- `docker/` and `docker-compose.yml`: local orchestration and DB initialization + +Prefer small, focused changes that keep frontend/backend contracts stable. + +## First Commands to Know +Use these commands first when validating changes. + +### Docker-first workflow (preferred) +- Start stack: `docker-compose up --build` +- Stop stack: `docker-compose down` +- Backend tests: `docker-compose exec backend npm test` +- Frontend unit tests: `docker-compose exec frontend npm test -- --run` +- Frontend E2E tests: `docker-compose exec frontend npm run test:e2e` +- Seed geodata: `docker-compose exec backend npm run seed-data` + +### Local workflow (without Docker) +- Root Node version: `nvm use` (uses `.nvmrc`, currently Node 24) +- Backend dev: `cd backend && npm install && npm run dev` +- Frontend dev: `cd frontend && npm install && npm run start` + +## Architecture and Boundaries +- Backend API entrypoint: `backend/app/server.js` + - Defines `GET /api/line-of-sight` and `GET /api/health` + - Performs destination/path generation and PostGIS querying +- Frontend app entrypoint: `frontend/src/App.jsx` + - Owns map lifecycle, animation, and UI state + - Calls backend via `frontend/src/services/api.js` +- Database bootstrap: `docker/init.sql` + - Enables PostGIS and creates `cities` with spatial index + +When changing behavior, prefer backend geospatial logic over duplicating calculations in the frontend. + +## Project Conventions +- Keep geospatial coordinates in WGS84 (`SRID 4326`) and preserve existing lat/lon parameter naming. +- Keep API responses JSON-compatible and backward compatible unless explicitly requested. +- Keep frontend styles in `frontend/src/styles/` (project uses custom CSS, not utility CSS frameworks). +- Avoid broad refactors in `App.jsx` unless task requires it; map lifecycle and refs are tightly coupled. + +## Testing Expectations +- Backend tests live in `backend/tests/` and use Jest + Supertest. +- Frontend unit tests live in `frontend/src/__tests__/` and use Vitest + Testing Library. +- E2E tests live in `frontend/e2e/` and use Playwright. +- For feature changes: + - Run the closest unit tests first. + - Run E2E tests when user flows or map interactions are changed. + +## Common Pitfalls +- PostGIS must be available before backend geospatial endpoints are exercised. +- `docker-compose.yml` pins Postgres service to `linux/arm64`; this can affect non-ARM hosts. +- Data import script (`backend/scripts/import_cities.js`) depends on `wget` and `unzip` availability. +- `README.md` contains roadmap/history notes; trust current scripts/config in source files first. + +## CI Notes +CI config is in `.gitea/workflows/test.yml`. +- Backend and frontend tests run on Node 24. +- Frontend unit tests run with `npm test -- --run`. +- E2E runs in Playwright container. + +## Link, Do Not Duplicate +For detailed setup and product context, reference: +- `README.md` +- `GEMINI.md` + +Keep this file focused on actionable agent guidance and repo-specific guardrails. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..28bca26 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Project Is + +**Line of Sight** is a geospatial web app that draws a great-circle line from a user-selected point on Earth in a chosen direction, then finds all cities along that path. It includes a flight-over animation that speeds up 20x over water and slows to normal speed over land. + +## Commands + +### Docker (preferred workflow) + +```bash +docker-compose up --build # Start all services (frontend :3050, backend :3001, postgres) +docker-compose down # Stop all services +docker-compose logs -f # View logs + +# Run tests inside containers +docker-compose exec backend npm test +docker-compose exec frontend npm test -- --run +docker-compose exec frontend npm run test:e2e +``` + +### Local development (without Docker) + +```bash +# Backend +cd backend && npm run dev # Nodemon dev server on :3001 + +# Frontend +cd frontend && npm run start # Vite dev server on :3050 +``` + +### Tests + +```bash +# Backend unit tests (Jest + Supertest) +cd backend && npm test + +# Frontend unit tests (Vitest) +cd frontend && npm test # Watch mode +cd frontend && npm test -- --run # Single run (CI mode) + +# E2E tests (Playwright) +cd frontend && npm run test:e2e + +# Seed the database with GeoNames city data +cd backend && npm run seed-data +``` + +### Build + +```bash +cd frontend && npm run build # Vite production build → frontend/build/ +``` + +## Architecture + +This is a monorepo with three services orchestrated by Docker Compose: + +``` +frontend/ → React 19 + Vite 6 SPA (MapLibre GL, Turf.js, Axios) +backend/ → Node 24 + Express 5 REST API +docker/ → PostgreSQL + PostGIS initialization +``` + +### Data flow + +1. User clicks map → sets start point (lat/lon) +2. User selects direction (0–360°) and tolerance (km radius) +3. Frontend calls `GET /api/line-of-sight?lat=&lon=&direction=&tolerance=` +4. Backend generates 80 path points along a great-circle arc (up to 20,000 km) +5. For each point, a PostGIS `ST_DWithin()` query finds cities within tolerance +6. Backend also checks whether each point is over land (500 km radius land check) +7. Response includes line coordinates with per-point water flags and matching cities +8. Frontend renders the path on a 3D globe map and shows city markers + +### Backend: `backend/app/server.js` + +Single-file Express app. Key internals: +- `calculateDestination()` — Haversine formula for great-circle destination points +- `GET /api/line-of-sight` — main endpoint: path generation + PostGIS city lookup +- `GET /api/health` — health check +- Returns up to 200 cities ordered by position along the line + +### Frontend: `frontend/src/App.jsx` + +Single large React component (~687 lines) managing all state and map logic: +- MapLibre GL map with 3D globe projection and terrain/sky effects +- Direction slider drives a live preview line before the user commits +- After "Show Line of Sight", map locks (prevents moving start point) +- Flight animation uses Turf.js to interpolate positions along the path; speed is 1× over land, 20× over water with smooth acceleration (0.005 step), predictive look-ahead of 2000 km +- New cities are fetched every 2000 km during flight via the same API +- Three map styles: light, dark, satellite (Esri tiles) + +### Database + +PostGIS `cities` table with a GIST index on `geom GEOGRAPHY(POINT, 4326)`. All coordinates use WGS84 / SRID 4326. The init SQL seeds 10 major cities; the `seed-data` script imports from GeoNames for a fuller dataset. + +## Conventions + +- **Geospatial**: WGS84 / SRID 4326 everywhere; PostGIS `GEOGRAPHY` type for distance queries +- **API**: RESTful; query params for `GET /api/line-of-sight`; env var `VITE_API_URL` (frontend) or `DATABASE_URL` (backend) +- **Styling**: Plain CSS files in `frontend/src/styles/` — no CSS framework +- **Tests**: Backend mocks the `pg` Pool; frontend mocks MapLibre GL and the API service. E2E tests use Playwright with a mock API response + +## CI + +Gitea workflow (`.gitea/workflows/test.yml`) runs on push/PR to `main`: +1. **backend-test** — `npm ci && npm test` +2. **frontend-test** — `npm ci && npm test -- --run` +3. **e2e-test** — uses `mcr.microsoft.com/playwright:v1.50.1` container diff --git a/README.md b/README.md index e1ee42e..b740c2d 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ docker-compose up --build ### 3. Access Application -- **Frontend**: http://localhost:3000 +- **Frontend**: http://localhost:3050 - **Backend API**: http://localhost:3001 - **Database**: localhost:5432 (PostgreSQL with PostGIS) @@ -219,7 +219,7 @@ npm run dev ### Port Already in Use ```bash # Find process using port -lsof -i :3000 +lsof -i :3050 lsof -i :3001 # Kill process diff --git a/docker-compose.yml b/docker-compose.yml index af9e1a8..ed2d685 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,7 +44,7 @@ services: dockerfile: Dockerfile container_name: line-of-sight-frontend ports: - - "3000:3000" + - "3050:3050" environment: - VITE_API_URL=http://localhost:3001/api depends_on: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6379caa..78f23c4 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -10,7 +10,7 @@ RUN npm install COPY . . # Expose port -EXPOSE 3000 +EXPOSE 3050 # Set environment variable for API URL ENV VITE_API_URL=http://localhost:3001/api diff --git a/frontend/e2e/app.spec.js b/frontend/e2e/app.spec.js index 3ad70f1..cd14b93 100644 --- a/frontend/e2e/app.spec.js +++ b/frontend/e2e/app.spec.js @@ -1,14 +1,80 @@ import { test, expect } from '@playwright/test'; +// MapLibre GL requires WebGL which isn't available in headless Chromium. +// Intercept the module request and return a lightweight mock so React can render. +const MAPLIBRE_MOCK = ` +class Map { + constructor(opts) { + this._listeners = {}; + Promise.resolve().then(() => this._emit('style.load')); + } + _emit(event) { + (this._listeners[event] || []).forEach(fn => fn()); + } + on(event, fn) { + if (!this._listeners[event]) this._listeners[event] = []; + this._listeners[event].push(fn); + return this; + } + off(event, fn) { + this._listeners[event] = (this._listeners[event] || []).filter(l => l !== fn); + return this; + } + remove() {} + getSource() { return null; } + getLayer() { return null; } + addSource() {} + addLayer() {} + removeLayer() {} + removeSource() {} + setSky() {} + setTerrain() {} + setLayoutProperty() {} + flyTo() {} + jumpTo() {} + setStyle() { Promise.resolve().then(() => this._emit('style.load')); } + getCanvas() { return document.createElement('canvas'); } + project() { return { x: 0, y: 0 }; } + unproject() { return { lng: 0, lat: 0 }; } +} +class Marker { + constructor(opts) { + this._el = (opts && opts.element) || document.createElement('div'); + } + setLngLat() { return this; } + addTo() { return this; } + remove() {} + getLngLat() { return { lng: 0, lat: 0 }; } + getElement() { return this._el; } +} +class Popup { + setLngLat() { return this; } + setDOMContent() { return this; } + addTo() { return this; } + remove() {} +} +class LngLatBounds { + extend() { return this; } +} +export default { Map, Marker, Popup, LngLatBounds }; +`; + +test.beforeEach(async ({ page }) => { + await page.route('**maplibre-gl.js*', async route => { + await route.fulfill({ contentType: 'application/javascript', body: MAPLIBRE_MOCK }); + }); +}); + test.describe('Line of Sight Application', () => { test('should load the home page and show settings', async ({ page }) => { await page.goto('/'); await expect(page.locator('text=Line of Sight Settings')).toBeVisible(); - await expect(page.locator('text=Direction')).toBeVisible(); + await expect(page.locator('label:has-text("Direction")')).toBeVisible(); }); test('should be able to toggle map style', async ({ page }) => { await page.goto('/'); + await page.waitForSelector('h3:text("Line of Sight Settings")', { timeout: 10000 }); const darkButton = page.locator('button:text("Dark")'); await darkButton.click(); await expect(darkButton).toHaveClass(/active-style/); @@ -16,10 +82,8 @@ test.describe('Line of Sight Application', () => { test('should show results when clicking the search button', async ({ page }) => { await page.goto('/'); - - // Mock the API response to avoid dependency on backend for E2E if desired, - // but here we let it hit the real backend if it's up. - // For a robust E2E in CI, we usually mock. + await page.waitForSelector('h3:text("Line of Sight Settings")', { timeout: 10000 }); + await page.route('**/api/line-of-sight*', async route => { const json = { success: true, diff --git a/frontend/playwright-report/index.html b/frontend/playwright-report/index.html index 2e24970..6a8013c 100644 --- a/frontend/playwright-report/index.html +++ b/frontend/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js index 7f0761d..7de1abd 100644 --- a/frontend/playwright.config.js +++ b/frontend/playwright.config.js @@ -8,9 +8,17 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: process.env.CI ? 'list' : 'html', use: { - baseURL: 'http://localhost:3000', + baseURL: 'http://localhost:3050', trace: 'on-first-retry', headless: true, + launchOptions: { + args: [ + '--enable-webgl', + '--ignore-gpu-blocklist', + '--use-gl=angle', + '--use-angle=swiftshader-webgl', + ], + }, }, projects: [ { @@ -20,7 +28,7 @@ export default defineConfig({ ], webServer: { command: 'npm run start', - url: 'http://localhost:3000', + url: 'http://localhost:3050', reuseExistingServer: !process.env.CI, }, }); diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json index 33809c2..cbcc1fb 100644 --- a/frontend/test-results/.last-run.json +++ b/frontend/test-results/.last-run.json @@ -1,8 +1,4 @@ { - "status": "failed", - "failedTests": [ - "a510cc93a867214a2ea0-87d527ee6ab9fa6e55ec", - "a510cc93a867214a2ea0-64f1332b15a123b767f1", - "a510cc93a867214a2ea0-ebcb65ad8c44378dd3e8" - ] + "status": "passed", + "failedTests": [] } \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index c0b100f..c4cc50e 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -10,7 +10,7 @@ export default defineConfig({ exclude: ['**/e2e/**', '**/node_modules/**'], }, server: { - port: 3000, + port: 3050, host: '0.0.0.0', }, build: { From 86aefba065de6362d2a0c12f9bceb283aa024b3d Mon Sep 17 00:00:00 2001 From: "(jenkins)" <(jenkins)> Date: Wed, 1 Apr 2026 11:16:03 +0100 Subject: [PATCH 4/5] Disable cache on gitea tests --- .gitea/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index d5deb07..899807a 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -30,8 +30,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: '24' - cache: 'npm' - cache-dependency-path: frontend/package-lock.json + # cache: 'npm' + # cache-dependency-path: frontend/package-lock.json - name: Install dependencies run: cd frontend && npm ci - name: Run unit tests @@ -47,8 +47,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: '24' - cache: 'npm' - cache-dependency-path: frontend/package-lock.json + # cache: 'npm' + # cache-dependency-path: frontend/package-lock.json - name: Install dependencies run: cd frontend && npm install - name: Run E2E tests From 205e2d5be53eb07dda0bcd5a99833d20c07e7abd Mon Sep 17 00:00:00 2001 From: "(jenkins)" <(jenkins)> Date: Wed, 1 Apr 2026 11:18:05 +0100 Subject: [PATCH 5/5] Update playwright version --- .gitea/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 899807a..38bc356 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -40,7 +40,7 @@ jobs: e2e-test: runs-on: ubuntu-latest container: - image: mcr.microsoft.com/playwright:v1.50.1-noble + image: mcr.microsoft.com/playwright:v1.58.2-noble steps: - uses: actions/checkout@v4 - name: Use Node.js